From 5d04c867c28d1912fa4cc70d507a28f721ff502f Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 17 Jun 2026 20:42:28 +0000 Subject: [PATCH 001/114] feat(diagnostics): add Diagnostic value-object model for the check gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the XPHP\Diagnostics namespace: a string-backed Severity enum (Error/Warning/Notice with isFailing()), a DiagnosticSource enum (xphp/phpstan), a SourceLocation (file/line/optional column), the immutable Diagnostic, and a mutable DiagnosticCollector (add/all/hasErrors/count). Pure value objects with no pipeline wiring yet — the foundation for the forthcoming `xphp check` command's structured, collect-all diagnostics. Unit tests cover severity gating, collector ordering/error detection, and defaults (100% mutation score over the new files). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Diagnostics/Diagnostic.php | 31 ++++++++++++ src/Diagnostics/DiagnosticCollector.php | 51 ++++++++++++++++++++ src/Diagnostics/DiagnosticSource.php | 16 ++++++ src/Diagnostics/Severity.php | 26 ++++++++++ src/Diagnostics/SourceLocation.php | 23 +++++++++ test/Diagnostics/DiagnosticCollectorTest.php | 49 +++++++++++++++++++ test/Diagnostics/DiagnosticTest.php | 46 ++++++++++++++++++ test/Diagnostics/SeverityTest.php | 32 ++++++++++++ test/Diagnostics/SourceLocationTest.php | 26 ++++++++++ 9 files changed, 300 insertions(+) create mode 100644 src/Diagnostics/Diagnostic.php create mode 100644 src/Diagnostics/DiagnosticCollector.php create mode 100644 src/Diagnostics/DiagnosticSource.php create mode 100644 src/Diagnostics/Severity.php create mode 100644 src/Diagnostics/SourceLocation.php create mode 100644 test/Diagnostics/DiagnosticCollectorTest.php create mode 100644 test/Diagnostics/DiagnosticTest.php create mode 100644 test/Diagnostics/SeverityTest.php create mode 100644 test/Diagnostics/SourceLocationTest.php diff --git a/src/Diagnostics/Diagnostic.php b/src/Diagnostics/Diagnostic.php new file mode 100644 index 0000000..327ba01 --- /dev/null +++ b/src/Diagnostics/Diagnostic.php @@ -0,0 +1,31 @@ +`) that + * surfaced a finding inside a template body — only set for Phase-2 (PHPStan) + * diagnostics mapped back to a declaration. + * + * Immutable. + */ +final readonly class Diagnostic +{ + public function __construct( + public Severity $severity, + public string $code, + public string $message, + public ?SourceLocation $location = null, + public ?string $triggeredBy = null, + public DiagnosticSource $source = DiagnosticSource::Xphp, + ) { + } +} diff --git a/src/Diagnostics/DiagnosticCollector.php b/src/Diagnostics/DiagnosticCollector.php new file mode 100644 index 0000000..72036d9 --- /dev/null +++ b/src/Diagnostics/DiagnosticCollector.php @@ -0,0 +1,51 @@ + */ + private array $diagnostics = []; + + public function add(Diagnostic $diagnostic): void + { + $this->diagnostics[] = $diagnostic; + } + + /** + * @return list In insertion order. + */ + public function all(): array + { + return $this->diagnostics; + } + + /** + * True iff any collected diagnostic fails the gate (Error severity). + */ + public function hasErrors(): bool + { + foreach ($this->diagnostics as $diagnostic) { + if ($diagnostic->severity->isFailing()) { + return true; + } + } + + return false; + } + + public function count(): int + { + return count($this->diagnostics); + } +} diff --git a/src/Diagnostics/DiagnosticSource.php b/src/Diagnostics/DiagnosticSource.php new file mode 100644 index 0000000..3df627e --- /dev/null +++ b/src/Diagnostics/DiagnosticSource.php @@ -0,0 +1,16 @@ +all()); + self::assertFalse($c->hasErrors()); + self::assertSame(0, $c->count()); + } + + public function testAddPreservesInsertionOrder(): void + { + $c = new DiagnosticCollector(); + $first = new Diagnostic(Severity::Notice, 'a', 'first'); + $second = new Diagnostic(Severity::Warning, 'b', 'second'); + $c->add($first); + $c->add($second); + + self::assertSame([$first, $second], $c->all()); + self::assertSame(2, $c->count()); + } + + public function testHasErrorsIsFalseWithoutErrorSeverity(): void + { + $c = new DiagnosticCollector(); + $c->add(new Diagnostic(Severity::Warning, 'a', 'w')); + $c->add(new Diagnostic(Severity::Notice, 'b', 'n')); + + self::assertFalse($c->hasErrors()); + } + + public function testHasErrorsIsTrueWhenAnyErrorPresent(): void + { + $c = new DiagnosticCollector(); + $c->add(new Diagnostic(Severity::Warning, 'a', 'w')); + $c->add(new Diagnostic(Severity::Error, 'b', 'e')); + + self::assertTrue($c->hasErrors()); + } +} diff --git a/test/Diagnostics/DiagnosticTest.php b/test/Diagnostics/DiagnosticTest.php new file mode 100644 index 0000000..bec683c --- /dev/null +++ b/test/Diagnostics/DiagnosticTest.php @@ -0,0 +1,46 @@ +severity); + self::assertSame('xphp.bound_violation', $d->code); + self::assertSame('boom', $d->message); + self::assertNull($d->location); + self::assertNull($d->triggeredBy); + self::assertSame(DiagnosticSource::Xphp, $d->source); + } + + public function testAllFieldsSet(): void + { + $loc = new SourceLocation('/src/Box.xphp', 12, 5); + $d = new Diagnostic( + Severity::Warning, + 'phpstan.return.type', + 'bad return', + $loc, + 'App\\Box', + DiagnosticSource::PhpStan, + ); + + self::assertSame(Severity::Warning, $d->severity); + self::assertSame($loc, $d->location); + self::assertSame('App\\Box', $d->triggeredBy); + self::assertSame(DiagnosticSource::PhpStan, $d->source); + } + + public function testSourceBackingValues(): void + { + self::assertSame('xphp', DiagnosticSource::Xphp->value); + self::assertSame('phpstan', DiagnosticSource::PhpStan->value); + } +} diff --git a/test/Diagnostics/SeverityTest.php b/test/Diagnostics/SeverityTest.php new file mode 100644 index 0000000..f8a2099 --- /dev/null +++ b/test/Diagnostics/SeverityTest.php @@ -0,0 +1,32 @@ +isFailing()); + } + + public function testWarningIsNotFailing(): void + { + self::assertFalse(Severity::Warning->isFailing()); + } + + public function testNoticeIsNotFailing(): void + { + self::assertFalse(Severity::Notice->isFailing()); + } + + public function testBackingValues(): void + { + self::assertSame('error', Severity::Error->value); + self::assertSame('warning', Severity::Warning->value); + self::assertSame('notice', Severity::Notice->value); + } +} diff --git a/test/Diagnostics/SourceLocationTest.php b/test/Diagnostics/SourceLocationTest.php new file mode 100644 index 0000000..3584eb3 --- /dev/null +++ b/test/Diagnostics/SourceLocationTest.php @@ -0,0 +1,26 @@ +file); + self::assertSame(42, $loc->line); + self::assertNull($loc->column); + } + + public function testColumnIsPreservedWhenGiven(): void + { + $loc = new SourceLocation('/src/Box.xphp', 42, 7); + + self::assertSame(7, $loc->column); + } +} From b9e117397e492d11b3c9d8754aee2e48a515d946 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 17 Jun 2026 20:54:05 +0000 Subject: [PATCH 002/114] feat(check): collect generic bound violations via an optional diagnostics sink Thread an optional DiagnosticCollector through the bound-validation path (Registry ctor -> recordInstantiation -> validateBounds -> checkBounds). When absent (xphp compile) violations throw exactly as before, byte-identical; when present each violation is appended as a Diagnostic -- located at the instantiation site captured from the AST node in RegistryCollector -- and recording continues so all violations surface in one run. The user-facing message now comes from a single shared boundViolationMessage() builder so the throw text and the diagnostic text can never drift. Tests cover collect-vs-throw, byte-identical and exact message text, multi-violation collection, and AST-derived source line (100% mutation score over the diff). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/Registry.php | 77 +++++++-- .../Monomorphize/RegistryCollector.php | 10 +- .../RegistryBoundsDiagnosticTest.php | 151 ++++++++++++++++++ 3 files changed, 226 insertions(+), 12 deletions(-) create mode 100644 test/Transpiler/Monomorphize/RegistryBoundsDiagnosticTest.php diff --git a/src/Transpiler/Monomorphize/Registry.php b/src/Transpiler/Monomorphize/Registry.php index de6fa00..a90ab7d 100644 --- a/src/Transpiler/Monomorphize/Registry.php +++ b/src/Transpiler/Monomorphize/Registry.php @@ -14,9 +14,16 @@ use PhpParser\Node\Stmt\ClassLike; use PhpParser\Node\UnionType; use RuntimeException; +use XPHP\Diagnostics\Diagnostic; +use XPHP\Diagnostics\DiagnosticCollector; +use XPHP\Diagnostics\Severity; +use XPHP\Diagnostics\SourceLocation; final class Registry { + /** Stable diagnostic code for a generic bound violation at an instantiation site. */ + public const CODE_BOUND_VIOLATION = 'xphp.bound_violation'; + /** * All specialized classes live under this namespace prefix; the full target FQCN * mirrors the original template's namespace and ends with a hash-based class name. @@ -39,9 +46,16 @@ final class Registry /** @var array Keyed by full generated FQCN. */ private array $instantiations = []; + /** + * @param ?DiagnosticCollector $diagnostics When null (the default, used by `xphp compile`), + * validation failures throw as before — byte-identical behavior. When provided (by + * `xphp check`), bound violations are appended to the collector and recording continues, + * so every error of the validation phase is reported in one run. + */ public function __construct( private readonly int $hashLength = self::DEFAULT_HASH_HEX_LENGTH, private readonly ?TypeHierarchy $hierarchy = null, + private readonly ?DiagnosticCollector $diagnostics = null, ) { self::validateHashLength($this->hashLength); } @@ -87,18 +101,24 @@ public function recordDefinition( * generated FQCN, throws with a self-contained error message explaining how to raise XPHP_HASH_LENGTH. * * @param list $args + * @param ?SourceLocation $callSite The `.xphp` position of the instantiation site, used to + * locate a bound-violation diagnostic in check-mode. Nested sub-instantiations inherit the + * enclosing site (they have no distinct source token). Ignored in throw-mode. */ - public function recordInstantiation(string $templateFqn, array $args): GenericInstantiation - { + public function recordInstantiation( + string $templateFqn, + array $args, + ?SourceLocation $callSite = null, + ): GenericInstantiation { $args = $this->padWithDefaults($templateFqn, $args); foreach ($args as $arg) { if ($arg->isGeneric()) { - $this->recordInstantiation($arg->name, $arg->args); + $this->recordInstantiation($arg->name, $arg->args, $callSite); } } - $this->validateBounds($templateFqn, $args); + $this->validateBounds($templateFqn, $args, $callSite); $generatedFqn = self::generatedFqn($templateFqn, $args, $this->hashLength); $template = ltrim($templateFqn, '\\'); @@ -609,7 +629,7 @@ private static function assertLeaf( * * @param list $args */ - private function validateBounds(string $templateFqn, array $args): void + private function validateBounds(string $templateFqn, array $args, ?SourceLocation $callSite = null): void { if ($this->hierarchy === null) { return; @@ -623,6 +643,8 @@ private function validateBounds(string $templateFqn, array $args): void $args, $this->hierarchy, self::formatInstantiation(ltrim($templateFqn, '\\'), $args), + $this->diagnostics, + $callSite, ); } @@ -637,6 +659,11 @@ private function validateBounds(string $templateFqn, array $args): void * `$instantiationLabel` is the human-readable context string that opens the error * (e.g. `"App\Box"` or `"App\Util::identity"`). * + * When `$diagnostics` is null (the default — `xphp compile`, and every `GenericMethodCompiler` + * call site) a violation throws as before (byte-identical message). When a collector is + * supplied (`xphp check`), each violation is appended as a `Diagnostic` and the loop continues, + * so all violating parameters of one instantiation are reported in a single run. + * * @param list $typeParams * @param list $args */ @@ -645,6 +672,8 @@ public static function checkBounds( array $args, TypeHierarchy $hierarchy, string $instantiationLabel, + ?DiagnosticCollector $diagnostics = null, + ?SourceLocation $callSite = null, ): void { // Arity mismatch is a different error class (caught upstream); skip silently here // so that the existing pipeline can produce the more specific message. @@ -675,20 +704,46 @@ public static function checkBounds( $concrete->toDisplayString(), $boundDisplay, ); - throw new RuntimeException(sprintf( - "Generic bound violated while instantiating %s.\n" - . " type parameter %s is bounded by %s\n" - . " but the supplied concrete type is %s\n\n" - . " %s", + $message = self::boundViolationMessage( $instantiationLabel, $param->name, $boundDisplay, $concrete->toDisplayString(), $detail, - )); + ); + if ($diagnostics !== null) { + $diagnostics->add(new Diagnostic(Severity::Error, self::CODE_BOUND_VIOLATION, $message, $callSite)); + continue; + } + throw new RuntimeException($message); } } + /** + * Build the user-facing bound-violation message. Single source of truth shared by the + * throw path (`xphp compile`) and the diagnostic path (`xphp check`) so the two can never + * drift — the exact text is pinned by `expectExceptionMessage` assertions. + */ + private static function boundViolationMessage( + string $instantiationLabel, + string $paramName, + string $boundDisplay, + string $concreteDisplay, + string $detail, + ): string { + return sprintf( + "Generic bound violated while instantiating %s.\n" + . " type parameter %s is bounded by %s\n" + . " but the supplied concrete type is %s\n\n" + . " %s", + $instantiationLabel, + $paramName, + $boundDisplay, + $concreteDisplay, + $detail, + ); + } + /** * Three-way verdict (true / false / null) for a bound expression against a * concrete TypeRef. Walks the BoundExpr tree: diff --git a/src/Transpiler/Monomorphize/RegistryCollector.php b/src/Transpiler/Monomorphize/RegistryCollector.php index 006e42a..205730f 100644 --- a/src/Transpiler/Monomorphize/RegistryCollector.php +++ b/src/Transpiler/Monomorphize/RegistryCollector.php @@ -13,6 +13,7 @@ use PhpParser\Node\Stmt\Use_; use PhpParser\NodeTraverser; use PhpParser\NodeVisitorAbstract; +use XPHP\Diagnostics\SourceLocation; /** * Walks an AST and feeds generic definitions and instantiations into a Registry. @@ -133,7 +134,11 @@ public function enterNode(Node $node): null if (is_array($args)) { /** @var list $args — set as a list by XphpSourceParser::resolveAndAttach. */ if (is_string($fqn) && self::allConcrete($args)) { - $this->registry->recordInstantiation($fqn, $args); + $this->registry->recordInstantiation( + $fqn, + $args, + new SourceLocation($this->currentFile, $node->getStartLine()), + ); } } } @@ -178,6 +183,9 @@ private function synthesizeBareNewIfAllDefaults(Name $name): void // instantiation downstream -- only the path differs. The split exists to // record the synthesis at the same time as the attribute attach so that the // fixed-point loop's nested-instantiation walk picks it up in the same pass. + // Call-site location is threaded for the explicit-turbofish path (the bound-violation + // case); the all-defaults bare-`new` path can only fail bounds via a default, which is + // reported at the definition site, so it records without a call-site here. $this->registry->recordInstantiation($resolved, []); } diff --git a/test/Transpiler/Monomorphize/RegistryBoundsDiagnosticTest.php b/test/Transpiler/Monomorphize/RegistryBoundsDiagnosticTest.php new file mode 100644 index 0000000..a7e28d3 --- /dev/null +++ b/test/Transpiler/Monomorphize/RegistryBoundsDiagnosticTest.php @@ -0,0 +1,151 @@ +boxRegistry($collector); + $loc = new SourceLocation('/App/Box.xphp', 7); + + // Must NOT throw. + $registry->recordInstantiation('App\\Box', [new TypeRef('int', isScalar: true)], $loc); + + self::assertTrue($collector->hasErrors()); + self::assertCount(1, $collector->all()); + + $d = $collector->all()[0]; + self::assertSame(Severity::Error, $d->severity); + self::assertSame(Registry::CODE_BOUND_VIOLATION, $d->code); + self::assertSame(DiagnosticSource::Xphp, $d->source); + self::assertSame($loc, $d->location); + self::assertStringContainsString('Generic bound violated', $d->message); + self::assertStringContainsString('"int" does not extend/implement "Stringable"', $d->message); + + // Recording still completed (continue-safely): the instantiation is on file. + self::assertCount(1, $registry->instantiations()); + } + + public function testCollectedMessageHasExactText(): void + { + $collector = new DiagnosticCollector(); + $this->boxRegistry($collector) + ->recordInstantiation('App\\Box', [new TypeRef('int', isScalar: true)]); + + $expected = <<<'TXT' + Generic bound violated while instantiating App\Box. + type parameter T is bounded by Stringable + but the supplied concrete type is int + + "int" does not extend/implement "Stringable". + TXT; + + self::assertSame($expected, $collector->all()[0]->message); + } + + public function testCollectedMessageIsByteIdenticalToThrownMessage(): void + { + $thrown = null; + try { + $this->boxRegistry(null) + ->recordInstantiation('App\\Box', [new TypeRef('int', isScalar: true)]); + self::fail('expected RuntimeException in throw-mode'); + } catch (RuntimeException $e) { + $thrown = $e->getMessage(); + } + + $collector = new DiagnosticCollector(); + $this->boxRegistry($collector) + ->recordInstantiation('App\\Box', [new TypeRef('int', isScalar: true)]); + + self::assertSame($thrown, $collector->all()[0]->message); + } + + public function testMultipleViolationsCollectedInOneRun(): void + { + $collector = new DiagnosticCollector(); + $hierarchy = new TypeHierarchy([]); + $registry = new Registry(hierarchy: $hierarchy, diagnostics: $collector); + $registry->recordDefinition( + 'App\\Pair', + 'Pair', + [ + new TypeParam('A', new BoundLeaf(new TypeRef('Stringable'))), + new TypeParam('B', new BoundLeaf(new TypeRef('Countable'))), + ], + new Class_(new Identifier('Pair')), + '/App/Pair.xphp', + ); + + $registry->recordInstantiation( + 'App\\Pair', + [new TypeRef('int', isScalar: true), new TypeRef('float', isScalar: true)], + ); + + self::assertCount(2, $collector->all()); + } + + public function testNodeLineIsCapturedFromTheInstantiationSite(): void + { + // Line 5 holds the `new Box::(...)` instantiation site. + $source = <<<'XPHP' + { public function __construct(public T $v) {} } + $b = new Box::(5); + XPHP; + + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($source); + + $collector = new DiagnosticCollector(); + $registry = new Registry( + hierarchy: TypeHierarchy::fromAstPerFile(['/App/Box.xphp' => $ast]), + diagnostics: $collector, + ); + $rc = new RegistryCollector($registry); + $rc->collectDefinitions($ast, '/App/Box.xphp'); + $rc->collectInstantiations($ast, '/App/Box.xphp'); + + self::assertCount(1, $collector->all()); + $loc = $collector->all()[0]->location; + self::assertNotNull($loc); + self::assertSame('/App/Box.xphp', $loc->file); + self::assertSame(5, $loc->line); + } + + private function boxRegistry(?DiagnosticCollector $collector): Registry + { + $registry = new Registry(hierarchy: new TypeHierarchy([]), diagnostics: $collector); + $registry->recordDefinition( + 'App\\Box', + 'Box', + [new TypeParam('T', new BoundLeaf(new TypeRef('Stringable')))], + new Class_(new Identifier('Box')), + '/App/Box.xphp', + ); + + return $registry; + } +} From 598a3fbfdaf1fccc5b52fd5f59ceb3444a29b28c Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 17 Jun 2026 21:34:30 +0000 Subject: [PATCH 003/114] feat(check): add a validate-only pass that collects generic errors 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) --- src/Transpiler/Monomorphize/Compiler.php | 61 ++++++-- src/Transpiler/Monomorphize/Registry.php | 138 +++++++++++++++--- .../Monomorphize/CheckPassIntegrationTest.php | 136 +++++++++++++++++ .../RegistryCheckDiagnosticsTest.php | 128 ++++++++++++++++ test/fixture/check/clean/source/Box.xphp | 12 ++ test/fixture/check/clean/source/Use.xphp | 7 + .../check/default_violation/source/Box.xphp | 14 ++ .../check/missing_arg/source/Pair.xphp | 12 ++ .../fixture/check/missing_arg/source/Use.xphp | 8 + .../fixture/check/multi_error/source/Box.xphp | 12 ++ .../fixture/check/multi_error/source/Use.xphp | 10 ++ .../check/undefined_template/source/Use.xphp | 8 + 12 files changed, 516 insertions(+), 30 deletions(-) create mode 100644 test/Transpiler/Monomorphize/CheckPassIntegrationTest.php create mode 100644 test/Transpiler/Monomorphize/RegistryCheckDiagnosticsTest.php create mode 100644 test/fixture/check/clean/source/Box.xphp create mode 100644 test/fixture/check/clean/source/Use.xphp create mode 100644 test/fixture/check/default_violation/source/Box.xphp create mode 100644 test/fixture/check/missing_arg/source/Pair.xphp create mode 100644 test/fixture/check/missing_arg/source/Use.xphp create mode 100644 test/fixture/check/multi_error/source/Box.xphp create mode 100644 test/fixture/check/multi_error/source/Use.xphp create mode 100644 test/fixture/check/undefined_template/source/Use.xphp diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 12868d2..e820290 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -6,6 +6,7 @@ use PhpParser\PrettyPrinter\Standard as StandardPrinter; use RuntimeException; +use XPHP\Diagnostics\DiagnosticCollector; use XPHP\FileSystem\FileReader; use XPHP\FileSystem\FileWriter; use XPHP\FileSystem\FilepathArray; @@ -51,11 +52,7 @@ public function compile( // Phase 0: parse every source up front. The TypeHierarchy (used to validate generic // bounds at recordInstantiation time) needs to see every class/interface/trait // declaration *before* any instantiation is recorded, so parsing has to finish first. - $astPerFile = []; - foreach ($sources->filepaths as $filepath) { - $content = $this->fileReader->read($filepath); - $astPerFile[$filepath] = $this->sourceParser->parse($content); - } + $astPerFile = $this->parseAll($sources); $hierarchy = TypeHierarchy::fromAstPerFile($astPerFile); $registry = new Registry($this->hashLength, $hierarchy); @@ -111,11 +108,9 @@ public function compile( $definition = $registry->definition($instantiation->templateFqn); if ($definition === null) { - throw new RuntimeException(sprintf( - 'Generic template "%s" was instantiated but never defined (generated as: %s).', - $instantiation->templateFqn, - $generatedFqn, - )); + throw new RuntimeException( + Registry::undefinedTemplateMessage($instantiation->templateFqn, $generatedFqn), + ); } $substitution = array_combine($definition->typeParamNames(), $instantiation->concreteTypes); @@ -209,6 +204,52 @@ public function compile( ); } + /** + * Validate-only pass for `xphp check`: parse, build the hierarchy, collect definitions, + * then validate (defaults-vs-bounds) and collect instantiations (bounds, missing args) + * with a DiagnosticCollector so every generic error is gathered instead of throwing on + * the first. Stops after validation — it never specializes or emits, so a partially-invalid + * registry never reaches the fixed-point loop. Returns the collected diagnostics. + * + * Scope note: method/function/closure-level generic checks (GenericMethodCompiler, Phase 1a + * of compile()) are intentionally NOT run here — they remain fail-fast and are not yet part + * of the check gate. Variance checks are wired in a later step. + */ + public function check(FilepathArray $sources): DiagnosticCollector + { + $astPerFile = $this->parseAll($sources); + $hierarchy = TypeHierarchy::fromAstPerFile($astPerFile); + $diagnostics = new DiagnosticCollector(); + $registry = new Registry($this->hashLength, $hierarchy, $diagnostics); + $collector = new RegistryCollector($registry); + + foreach ($astPerFile as $filepath => $ast) { + $collector->collectDefinitions($ast, $filepath); + } + $registry->validateDefaultsAgainstBounds(); + foreach ($astPerFile as $filepath => $ast) { + $collector->collectInstantiations($ast, $filepath); + } + $registry->collectUndefinedTemplates($diagnostics); + + return $diagnostics; + } + + /** + * Parse every source file into an AST keyed by filepath. + * + * @return array> + */ + private function parseAll(FilepathArray $sources): array + { + $astPerFile = []; + foreach ($sources->filepaths as $filepath) { + $astPerFile[$filepath] = $this->sourceParser->parse($this->fileReader->read($filepath)); + } + + return $astPerFile; + } + private static function relativePath(string $base, string $filepath): string { $base = rtrim($base, '/') . '/'; diff --git a/src/Transpiler/Monomorphize/Registry.php b/src/Transpiler/Monomorphize/Registry.php index a90ab7d..d477741 100644 --- a/src/Transpiler/Monomorphize/Registry.php +++ b/src/Transpiler/Monomorphize/Registry.php @@ -21,8 +21,11 @@ final class Registry { - /** Stable diagnostic code for a generic bound violation at an instantiation site. */ + /** Stable diagnostic codes (machine identifiers tooling can match on). */ public const CODE_BOUND_VIOLATION = 'xphp.bound_violation'; + public const CODE_MISSING_TYPE_ARGUMENT = 'xphp.missing_type_argument'; + public const CODE_DEFAULT_BOUND_VIOLATION = 'xphp.default_bound_violation'; + public const CODE_UNDEFINED_TEMPLATE = 'xphp.undefined_template'; /** * All specialized classes live under this namespace prefix; the full target FQCN @@ -71,6 +74,11 @@ public function recordDefinition( string $sourceFile, ): void { if (isset($this->definitions[$templateFqn])) { + // NB: cross-file duplicate class templates are filtered out earlier by + // RegistryCollector's `!isAlreadyRecorded()` guard, so this throw is only + // reachable via the generic-function path. Surfacing duplicate definitions in + // `xphp check` would require reworking that guard (and would change compile-mode + // semantics), so it is intentionally NOT part of the collector seam — deferred. throw new RuntimeException(sprintf( 'Generic template "%s" already declared (in %s); duplicate declaration in %s.', $templateFqn, @@ -110,7 +118,7 @@ public function recordInstantiation( array $args, ?SourceLocation $callSite = null, ): GenericInstantiation { - $args = $this->padWithDefaults($templateFqn, $args); + $args = $this->padWithDefaults($templateFqn, $args, $callSite); foreach ($args as $arg) { if ($arg->isGeneric()) { @@ -157,7 +165,7 @@ public function recordInstantiation( * @param list $args * @return list */ - private function padWithDefaults(string $templateFqn, array $args): array + private function padWithDefaults(string $templateFqn, array $args, ?SourceLocation $callSite = null): array { $definition = $this->definitions[ltrim($templateFqn, '\\')] ?? null; if ($definition === null) { @@ -167,6 +175,8 @@ private function padWithDefaults(string $templateFqn, array $args): array $definition->typeParams, $args, ltrim($templateFqn, '\\'), + $this->diagnostics, + $callSite, ); } @@ -178,9 +188,10 @@ private function padWithDefaults(string $templateFqn, array $args): array * templates) so the padding semantics stay identical regardless of * the call-site shape. * - * Throws when a non-defaulted param is missing and there are fewer - * supplied args than required. Returns `$args` unchanged when the - * supplied count already matches or exceeds the param count. + * When `$diagnostics` is null (compile, and every `GenericMethodCompiler` call) a missing + * non-defaulted param throws as before. With a collector (check) it appends a Diagnostic and + * returns the partial padding gathered so far, so the run continues to surface other errors. + * Returns `$args` unchanged when the supplied count already matches or exceeds the param count. * * @param list $params * @param list $args @@ -190,6 +201,8 @@ public static function padArgsWithDefaults( array $params, array $args, string $templateLabel, + ?DiagnosticCollector $diagnostics = null, + ?SourceLocation $callSite = null, ): array { $supplied = count($args); $needed = count($params); @@ -200,15 +213,18 @@ public static function padArgsWithDefaults( $padded = $args; for ($i = $supplied; $i < $needed; $i++) { if ($params[$i]->default === null) { - throw new RuntimeException(sprintf( - 'Generic template "%s" was instantiated with %d type argument(s) ' - . 'but parameter `%s` (position %d) has no default; supply it ' - . 'explicitly or add defaults to every preceding required parameter.', - $templateLabel, - $supplied, - $params[$i]->name, - $i + 1, - )); + $message = self::missingTypeArgumentMessage($templateLabel, $supplied, $params[$i]->name, $i + 1); + if ($diagnostics !== null) { + $diagnostics->add(new Diagnostic( + Severity::Error, + self::CODE_MISSING_TYPE_ARGUMENT, + $message, + $callSite, + )); + + return $padded; + } + throw new RuntimeException($message); } $subst = []; foreach ($padded as $j => $concrete) { @@ -219,6 +235,26 @@ public static function padArgsWithDefaults( return $padded; } + /** + * Single source of truth for the missing-required-type-argument message. + */ + private static function missingTypeArgumentMessage( + string $templateLabel, + int $supplied, + string $paramName, + int $position, + ): string { + return sprintf( + 'Generic template "%s" was instantiated with %d type argument(s) ' + . 'but parameter `%s` (position %d) has no default; supply it ' + . 'explicitly or add defaults to every preceding required parameter.', + $templateLabel, + $supplied, + $paramName, + $position, + ); + } + /** * Declaration-time bound check on fully-concrete defaults. Defaults that * reference earlier type-params can't be checked here because the bound @@ -261,21 +297,83 @@ public function validateDefaultsAgainstBounds(): void . 'it satisfies "%s".', $boundDisplay, ); - throw new RuntimeException(sprintf( - "Default for generic parameter `%s` of \"%s\" violates the parameter's bound.\n" - . " bound: %s\n" - . " default: %s\n" - . " reason: %s", + $message = self::defaultBoundViolationMessage( $param->name, $definition->templateFqn, $boundDisplay, $defaultDisplay, $reason, + ); + if ($this->diagnostics !== null) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + self::CODE_DEFAULT_BOUND_VIOLATION, + $message, + new SourceLocation($definition->sourceFile, $definition->templateAst->getStartLine()), + )); + continue; + } + throw new RuntimeException($message); + } + } + } + + /** + * Report every recorded instantiation whose template was never defined. In `xphp compile` + * this surfaces as a thrown error inside the specialization loop; `xphp check` doesn't run + * that loop, so it detects the same condition here by comparing recorded instantiations + * against the definition set. No source location is attached — the instantiation does not + * retain its call site — but the message names the template and its generated FQCN. + */ + public function collectUndefinedTemplates(DiagnosticCollector $diagnostics): void + { + foreach ($this->instantiations as $generatedFqn => $instantiation) { + if (!isset($this->definitions[$instantiation->templateFqn])) { + $diagnostics->add(new Diagnostic( + Severity::Error, + self::CODE_UNDEFINED_TEMPLATE, + self::undefinedTemplateMessage($instantiation->templateFqn, $generatedFqn), )); } } } + /** + * Single source of truth for the "instantiated but never defined" message, shared by the + * compile-time throw (Compiler) and the check-time diagnostic. + */ + public static function undefinedTemplateMessage(string $templateFqn, string $generatedFqn): string + { + return sprintf( + 'Generic template "%s" was instantiated but never defined (generated as: %s).', + $templateFqn, + $generatedFqn, + ); + } + + /** + * Single source of truth for the default-violates-bound message. + */ + private static function defaultBoundViolationMessage( + string $paramName, + string $templateFqn, + string $boundDisplay, + string $defaultDisplay, + string $reason, + ): string { + return sprintf( + "Default for generic parameter `%s` of \"%s\" violates the parameter's bound.\n" + . " bound: %s\n" + . " default: %s\n" + . " reason: %s", + $paramName, + $templateFqn, + $boundDisplay, + $defaultDisplay, + $reason, + ); + } + /** * Inner-template variance composition pass. Runs after `collectDefinitions` * but before `collectInstantiations`, so every template's variance markers diff --git a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php new file mode 100644 index 0000000..ceff18d --- /dev/null +++ b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php @@ -0,0 +1,136 @@ +check('multi_error'); + + self::assertTrue($diagnostics->hasErrors()); + self::assertCount(2, $diagnostics->all()); + foreach ($diagnostics->all() as $d) { + self::assertSame(Registry::CODE_BOUND_VIOLATION, $d->code); + self::assertNotNull($d->location); + self::assertStringEndsWith('Use.xphp', $d->location->file); + } + } + + public function testCleanSourcesProduceNoDiagnostics(): void + { + $diagnostics = $this->check('clean'); + + self::assertFalse($diagnostics->hasErrors()); + self::assertSame([], $diagnostics->all()); + } + + public function testDefaultBoundViolationIsCollectedByCheck(): void + { + // Exercises the validateDefaultsAgainstBounds() step of check(). + $diagnostics = $this->check('default_violation'); + + self::assertCount(1, $diagnostics->all()); + self::assertSame(Registry::CODE_DEFAULT_BOUND_VIOLATION, $diagnostics->all()[0]->code); + } + + public function testMissingTypeArgumentIsCollectedByCheck(): void + { + $diagnostics = $this->check('missing_arg'); + + self::assertCount(1, $diagnostics->all()); + self::assertSame(Registry::CODE_MISSING_TYPE_ARGUMENT, $diagnostics->all()[0]->code); + self::assertNotNull($diagnostics->all()[0]->location); + } + + public function testUndefinedTemplateIsCollectedByCheck(): void + { + // Exercises the collectUndefinedTemplates() step of check(). + $diagnostics = $this->check('undefined_template'); + + self::assertCount(1, $diagnostics->all()); + self::assertSame(Registry::CODE_UNDEFINED_TEMPLATE, $diagnostics->all()[0]->code); + } + + public function testCompileStillThrowsOnUndefinedTemplate(): void + { + // The same condition is a hard error in compile-mode (no collector) — confirms the + // shared message builder keeps the throw path intact. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('was instantiated but never defined'); + + $work = sys_get_temp_dir() . '/xphp-check-compile-' . uniqid('', true); + mkdir($work, 0o755, true); + try { + $this->buildCompiler()->compile($this->sources('undefined_template'), $this->sourceDir('undefined_template'), $work . '/dist', $work . '/cache'); + } finally { + self::rrmdir($work); + } + } + + private function check(string $fixture): DiagnosticCollector + { + return $this->buildCompiler()->check($this->sources($fixture)); + } + + private function sources(string $fixture): FilepathArray + { + return (new NativeFileFinder()) + ->find($this->sourceDir($fixture)) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + } + + private function sourceDir(string $fixture): string + { + return realpath(__DIR__ . '/../../fixture/check/' . $fixture . '/source') + ?: throw new RuntimeException("Fixture missing: {$fixture}"); + } + + private static function rrmdir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . '/' . $entry; + is_dir($path) ? self::rrmdir($path) : unlink($path); + } + rmdir($dir); + } + + private function buildCompiler(): Compiler + { + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + + return new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + } +} diff --git a/test/Transpiler/Monomorphize/RegistryCheckDiagnosticsTest.php b/test/Transpiler/Monomorphize/RegistryCheckDiagnosticsTest.php new file mode 100644 index 0000000..1fa6514 --- /dev/null +++ b/test/Transpiler/Monomorphize/RegistryCheckDiagnosticsTest.php @@ -0,0 +1,128 @@ +recordDefinition( + 'App\\Pair', + 'Pair', + [new TypeParam('A'), new TypeParam('B')], // B has no default + $this->classAt(1), + '/Pair.xphp', + ); + + $registry->recordInstantiation('App\\Pair', [new TypeRef('int', isScalar: true)], new SourceLocation('/Use.xphp', 8)); + + self::assertCount(1, $collector->all()); + $d = $collector->all()[0]; + self::assertSame(Registry::CODE_MISSING_TYPE_ARGUMENT, $d->code); + self::assertEquals(new SourceLocation('/Use.xphp', 8), $d->location); + self::assertSame( + 'Generic template "App\\Pair" was instantiated with 1 type argument(s) ' + . 'but parameter `B` (position 2) has no default; supply it ' + . 'explicitly or add defaults to every preceding required parameter.', + $d->message, + ); + } + + public function testDefaultBoundViolationIsCollectedAtDeclaration(): void + { + $collector = new DiagnosticCollector(); + $registry = new Registry(hierarchy: new TypeHierarchy([]), diagnostics: $collector); + $registry->recordDefinition( + 'App\\Box', + 'Box', + [new TypeParam('T', new BoundLeaf(new TypeRef('Stringable')), new TypeRef('int', isScalar: true))], + $this->classAt(4), + '/Box.xphp', + ); + + $registry->validateDefaultsAgainstBounds(); + + self::assertCount(1, $collector->all()); + $d = $collector->all()[0]; + self::assertSame(Registry::CODE_DEFAULT_BOUND_VIOLATION, $d->code); + self::assertEquals(new SourceLocation('/Box.xphp', 4), $d->location); + + $expected = <<<'TXT' + Default for generic parameter `T` of "App\Box" violates the parameter's bound. + bound: Stringable + default: int + reason: does not satisfy "Stringable". + TXT; + self::assertSame($expected, $d->message); + } + + public function testMultipleDefaultViolationsCollectedForOneDefinition(): void + { + $collector = new DiagnosticCollector(); + $registry = new Registry(hierarchy: new TypeHierarchy([]), diagnostics: $collector); + $registry->recordDefinition( + 'App\\Pair', + 'Pair', + [ + new TypeParam('A', new BoundLeaf(new TypeRef('Stringable')), new TypeRef('int', isScalar: true)), + new TypeParam('B', new BoundLeaf(new TypeRef('Countable')), new TypeRef('float', isScalar: true)), + ], + $this->classAt(1), + '/Pair.xphp', + ); + + $registry->validateDefaultsAgainstBounds(); + + // Both violating defaults are reported — the per-param loop continues past the first. + self::assertCount(2, $collector->all()); + } + + public function testUndefinedTemplateIsCollected(): void + { + $collector = new DiagnosticCollector(); + $registry = new Registry(diagnostics: $collector); + + // No definition recorded for App\Ghost. + $registry->recordInstantiation('App\\Ghost', [new TypeRef('int', isScalar: true)]); + $registry->collectUndefinedTemplates($collector); + + self::assertCount(1, $collector->all()); + $d = $collector->all()[0]; + self::assertSame(Registry::CODE_UNDEFINED_TEMPLATE, $d->code); + self::assertNull($d->location); + self::assertStringContainsString('Generic template "App\\Ghost" was instantiated but never defined', $d->message); + } + + public function testDefinedTemplatesProduceNoUndefinedDiagnostics(): void + { + $collector = new DiagnosticCollector(); + $registry = new Registry(diagnostics: $collector); + $registry->recordDefinition('App\\Box', 'Box', [new TypeParam('T')], $this->classAt(1), '/Box.xphp'); + $registry->recordInstantiation('App\\Box', [new TypeRef('int', isScalar: true)]); + + $registry->collectUndefinedTemplates($collector); + + self::assertSame([], $collector->all()); + } + + private function classAt(int $line): Class_ + { + return new Class_(new Identifier('C'), [], ['startLine' => $line]); + } +} diff --git a/test/fixture/check/clean/source/Box.xphp b/test/fixture/check/clean/source/Box.xphp new file mode 100644 index 0000000..ae32895 --- /dev/null +++ b/test/fixture/check/clean/source/Box.xphp @@ -0,0 +1,12 @@ + +{ + public function __construct(public T $value) + { + } +} diff --git a/test/fixture/check/clean/source/Use.xphp b/test/fixture/check/clean/source/Use.xphp new file mode 100644 index 0000000..7269797 --- /dev/null +++ b/test/fixture/check/clean/source/Use.xphp @@ -0,0 +1,7 @@ +(1); diff --git a/test/fixture/check/default_violation/source/Box.xphp b/test/fixture/check/default_violation/source/Box.xphp new file mode 100644 index 0000000..675d221 --- /dev/null +++ b/test/fixture/check/default_violation/source/Box.xphp @@ -0,0 +1,14 @@ + +{ + public function __construct(public T $value) + { + } +} diff --git a/test/fixture/check/missing_arg/source/Pair.xphp b/test/fixture/check/missing_arg/source/Pair.xphp new file mode 100644 index 0000000..17248ed --- /dev/null +++ b/test/fixture/check/missing_arg/source/Pair.xphp @@ -0,0 +1,12 @@ + +{ + public function __construct(public A $a, public B $b) + { + } +} diff --git a/test/fixture/check/missing_arg/source/Use.xphp b/test/fixture/check/missing_arg/source/Use.xphp new file mode 100644 index 0000000..b48dc66 --- /dev/null +++ b/test/fixture/check/missing_arg/source/Use.xphp @@ -0,0 +1,8 @@ +(1, 2); diff --git a/test/fixture/check/multi_error/source/Box.xphp b/test/fixture/check/multi_error/source/Box.xphp new file mode 100644 index 0000000..bac8e95 --- /dev/null +++ b/test/fixture/check/multi_error/source/Box.xphp @@ -0,0 +1,12 @@ + +{ + public function __construct(public T $value) + { + } +} diff --git a/test/fixture/check/multi_error/source/Use.xphp b/test/fixture/check/multi_error/source/Use.xphp new file mode 100644 index 0000000..44bcd6e --- /dev/null +++ b/test/fixture/check/multi_error/source/Use.xphp @@ -0,0 +1,10 @@ +(1); +$b = new Box::(2.0); diff --git a/test/fixture/check/undefined_template/source/Use.xphp b/test/fixture/check/undefined_template/source/Use.xphp new file mode 100644 index 0000000..cad0f20 --- /dev/null +++ b/test/fixture/check/undefined_template/source/Use.xphp @@ -0,0 +1,8 @@ +(1); From 6f86e03c14b7213dcc632500cdb49cc4f03265e8 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 17 Jun 2026 21:51:43 +0000 Subject: [PATCH 004/114] refactor(check): move variance-position validation to a collectable phase 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) --- src/Transpiler/Monomorphize/Compiler.php | 9 + src/Transpiler/Monomorphize/Registry.php | 18 ++ .../VariancePositionValidator.php | 211 +++++++++-------- .../Monomorphize/XphpSourceParser.php | 20 +- .../Monomorphize/CheckPassIntegrationTest.php | 23 ++ .../VariancePositionPhaseTest.php | 134 +++++++++++ .../Monomorphize/XphpSourceParserTest.php | 212 ------------------ .../variance_violation/source/Producer.xphp | 13 ++ 8 files changed, 316 insertions(+), 324 deletions(-) create mode 100644 test/Transpiler/Monomorphize/VariancePositionPhaseTest.php create mode 100644 test/fixture/check/variance_violation/source/Producer.xphp diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index e820290..0f5bff8 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -82,6 +82,11 @@ public function compile( // padded instantiation is recorded), then collect instantiations -- including // bare `new Foo;` shapes for templates whose every param has a default. $registry->validateDefaultsAgainstBounds(); + // Variance-position rules (covariant T in input, contravariant T in output, + // bound/default/property/constructor invariance, F-bounded variance). Moved + // here from the parser so all definitions are present and `xphp check` can + // collect across files; compile-mode still throws on the first violation. + $registry->validateVariancePositions(); // Inner-template variance composition: every template's variance // markers are known by now, so cases the parse-time validator // couldn't catch (e.g. `class P<+T> { f(): Container }` where @@ -227,6 +232,10 @@ public function check(FilepathArray $sources): DiagnosticCollector $collector->collectDefinitions($ast, $filepath); } $registry->validateDefaultsAgainstBounds(); + $registry->validateVariancePositions(); + // NB: validateInnerVariance() is not wired in here yet — it still throws + // (not collectable until the inner-variance step), so calling it in check-mode + // would abort on the first violation. Added once it accepts the collector. foreach ($astPerFile as $filepath => $ast) { $collector->collectInstantiations($ast, $filepath); } diff --git a/src/Transpiler/Monomorphize/Registry.php b/src/Transpiler/Monomorphize/Registry.php index d477741..29e7688 100644 --- a/src/Transpiler/Monomorphize/Registry.php +++ b/src/Transpiler/Monomorphize/Registry.php @@ -430,6 +430,24 @@ private static function defaultBoundViolationMessage( * builder -- Invariant declared never reaches the throw (Invariant * passes every allowed-list), so the arm is observably unreachable. */ + /** + * Variance-position check over every collected definition (moved out of the parser so + * `xphp check` can collect all violations across files in one run). Delegates to + * {@see VariancePositionValidator}: with this Registry's collector it gathers diagnostics + * at each offending member; without one (compile) it throws the first violation. + */ + public function validateVariancePositions(): void + { + foreach ($this->definitions as $definition) { + VariancePositionValidator::assertPositions( + $definition->templateAst, + $definition->typeParams, + $this->diagnostics, + $definition->sourceFile, + ); + } + } + public function validateInnerVariance(): void { foreach ($this->definitions as $definition) { diff --git a/src/Transpiler/Monomorphize/VariancePositionValidator.php b/src/Transpiler/Monomorphize/VariancePositionValidator.php index b64788b..6e1abfc 100644 --- a/src/Transpiler/Monomorphize/VariancePositionValidator.php +++ b/src/Transpiler/Monomorphize/VariancePositionValidator.php @@ -18,6 +18,10 @@ use PhpParser\Node\Stmt\Property; use PhpParser\Node\UnionType; use RuntimeException; +use XPHP\Diagnostics\Diagnostic; +use XPHP\Diagnostics\DiagnosticCollector; +use XPHP\Diagnostics\Severity; +use XPHP\Diagnostics\SourceLocation; /** * Declaration-time check that every appearance of a variance-marked type @@ -49,14 +53,40 @@ * * Errors include the param name, variance marker, and the position class * so the user sees what's wrong without reading the implementation. + * + * Runs over collected definitions (`Registry::validateVariancePositions`), not + * in the parser. With a `DiagnosticCollector` it gathers every violation in the + * class (each located at the offending member) and continues; without one it + * throws the first violation — byte-identical to the previous parse-time check. */ final class VariancePositionValidator { + /** Stable diagnostic code for a variance-position violation. */ + public const CODE_VARIANCE_POSITION = 'xphp.variance_position'; + + /** @var array */ + private array $varianceByName; + + /** @var list */ + private array $violations = []; + /** - * @param list $params + * @param array $varianceByName */ - public static function assertPositions(ClassLike $node, array $params): void + private function __construct(array $varianceByName) { + $this->varianceByName = $varianceByName; + } + + /** + * @param list $params + */ + public static function assertPositions( + ClassLike $node, + array $params, + ?DiagnosticCollector $diagnostics = null, + ?string $file = null, + ): void { $varianceByName = []; foreach ($params as $param) { if ($param->variance !== Variance::Invariant) { @@ -67,73 +97,82 @@ public static function assertPositions(ClassLike $node, array $params): void return; } - // 1. Bound and default positions are invariant by RFC. Walk each - // param's bound expression + default TypeRef tree; reject if any - // referenced leaf carries a name whose variance isn't Invariant. + $validator = new self($varianceByName); + $validator->collect($node, $params); + if ($validator->violations === []) { + return; + } + + if ($diagnostics === null) { + // Compile-mode: fail fast on the first violation (byte-identical message). + throw new RuntimeException($validator->violations[0]['message']); + } + + foreach ($validator->violations as $violation) { + $location = ($violation['line'] !== null && $file !== null) + ? new SourceLocation($file, $violation['line']) + : null; + $diagnostics->add(new Diagnostic( + Severity::Error, + self::CODE_VARIANCE_POSITION, + $violation['message'], + $location, + )); + } + } + + /** + * @param list $params + */ + private function collect(ClassLike $node, array $params): void + { + $declarationLine = $node->getStartLine(); + + // 1. Bound and default positions are invariant by RFC. foreach ($params as $param) { if ($param->bound !== null) { - self::checkBoundExpr($param->bound, $varianceByName, $param->name, 'bound'); + $this->checkBoundExpr($param->bound, $param->name, 'bound', $declarationLine); } if ($param->default !== null) { - self::checkTypeRef($param->default, $varianceByName, $param->name, 'default'); + $this->checkTypeRef($param->default, $param->name, 'default', $declarationLine); } } // 2. Class-body positions: properties and methods. foreach ($node->getProperties() as $property) { - self::checkProperty($property, $varianceByName); + $this->checkProperty($property); } foreach ($node->getMethods() as $method) { - self::checkMethod($method, $varianceByName); + $this->checkMethod($method); } } - /** - * @param array $varianceByName - */ - private static function checkBoundExpr( - BoundExpr $bound, - array $varianceByName, - string $hostParam, - string $hostPosition, - ): void { + private function checkBoundExpr(BoundExpr $bound, string $hostParam, string $hostPosition, int $line): void + { if ($bound instanceof BoundLeaf) { - self::checkTypeRef($bound->type, $varianceByName, $hostParam, $hostPosition); + $this->checkTypeRef($bound->type, $hostParam, $hostPosition, $line); return; } assert($bound instanceof BoundIntersection || $bound instanceof BoundUnion); foreach ($bound->operands as $operand) { - self::checkBoundExpr($operand, $varianceByName, $hostParam, $hostPosition); + $this->checkBoundExpr($operand, $hostParam, $hostPosition, $line); } } - /** - * @param array $varianceByName - */ - private static function checkTypeRef( - TypeRef $ref, - array $varianceByName, - string $hostParam, - string $hostPosition, - ): void { - if ($ref->isTypeParam && isset($varianceByName[$ref->name])) { - $variance = $varianceByName[$ref->name]; - throw self::violationError( - paramName: $ref->name, - variance: $variance, - position: $hostPosition, - hostParam: $hostParam, + private function checkTypeRef(TypeRef $ref, string $hostParam, string $hostPosition, int $line): void + { + if ($ref->isTypeParam && isset($this->varianceByName[$ref->name])) { + $this->record( + self::violationMessage($ref->name, $this->varianceByName[$ref->name], $hostPosition, $hostParam), + $line, ); } foreach ($ref->args as $inner) { - self::checkTypeRef($inner, $varianceByName, $hostParam, $hostPosition); + $this->checkTypeRef($inner, $hostParam, $hostPosition, $line); } } - /** - * @param array $varianceByName - */ - private static function checkProperty(Property $property, array $varianceByName): void + private function checkProperty(Property $property): void { $type = $property->type; if ($type === null) { @@ -143,13 +182,10 @@ private static function checkProperty(Property $property, array $varianceByName) // regardless of `readonly`. Even +T on a readonly property would // PHP-fatal at autoload when the variance edge lands. $position = $property->isReadonly() ? 'readonly property' : 'mutable property'; - self::checkPhpType($type, $varianceByName, [Variance::Invariant], $position); + $this->checkPhpType($type, [Variance::Invariant], $position); } - /** - * @param array $varianceByName - */ - private static function checkMethod(ClassMethod $method, array $varianceByName): void + private function checkMethod(ClassMethod $method): void { $name = $method->name->toLowerString(); $isConstructor = $name === '__construct'; @@ -166,16 +202,15 @@ private static function checkMethod(ClassMethod $method, array $varianceByName): continue; } if ($param->type !== null) { - self::checkPhpType($param->type, $varianceByName, $paramAllowed, $paramPosition); + $this->checkPhpType($param->type, $paramAllowed, $paramPosition); } } // Return type. Constructors don't have one; for the rest, invariant // or covariant. if (!$isConstructor && $method->returnType !== null) { - self::checkPhpType( + $this->checkPhpType( $method->returnType, - $varianceByName, [Variance::Invariant, Variance::Covariant], 'method return', ); @@ -190,7 +225,7 @@ private static function checkMethod(ClassMethod $method, array $varianceByName): // then nested closures have no type-params and every name in their // signature is an outer reference. if ($method->stmts !== null) { - self::walkBodyForNestedClosures($method->stmts, $varianceByName); + $this->walkBodyForNestedClosures($method->stmts); } } @@ -201,27 +236,23 @@ private static function checkMethod(ClassMethod $method, array $varianceByName): * * Cheap hand-rolled recursive walk -- avoids spinning up a NodeTraverser * inside the per-class validator hot path. - * - * @param array $varianceByName */ - private static function walkBodyForNestedClosures(mixed $node, array $varianceByName): void + private function walkBodyForNestedClosures(mixed $node): void { if ($node instanceof Closure || $node instanceof ArrowFunction) { foreach ($node->params as $param) { // @phpstan-ignore-next-line instanceof.alwaysTrue — defensive guard against nikic/php-parser PHPDoc-narrowed param collection element. if ($param instanceof Param && $param->type !== null) { - self::checkPhpType( + $this->checkPhpType( $param->type, - $varianceByName, [Variance::Invariant, Variance::Contravariant], 'nested closure/arrow parameter', ); } } if ($node->returnType !== null) { - self::checkPhpType( + $this->checkPhpType( $node->returnType, - $varianceByName, [Variance::Invariant, Variance::Covariant], 'nested closure/arrow return', ); @@ -231,13 +262,13 @@ private static function walkBodyForNestedClosures(mixed $node, array $varianceBy if (is_array($node)) { foreach ($node as $child) { - self::walkBodyForNestedClosures($child, $varianceByName); + $this->walkBodyForNestedClosures($child); } return; } if ($node instanceof Node) { foreach ($node->getSubNodeNames() as $subName) { - self::walkBodyForNestedClosures($node->$subName, $varianceByName); + $this->walkBodyForNestedClosures($node->$subName); } } } @@ -247,15 +278,10 @@ private static function walkBodyForNestedClosures(mixed $node, array $varianceBy * UnionType / IntersectionType), checking every Name's parts against * the variance map. * - * @param array $varianceByName * @param list $allowed */ - private static function checkPhpType( - Node $type, - array $varianceByName, - array $allowed, - string $position, - ): void { + private function checkPhpType(Node $type, array $allowed, string $position): void + { if ($type instanceof Identifier) { return; // scalar / pseudo type; never a type-param ref. } @@ -263,14 +289,12 @@ private static function checkPhpType( $parts = $type->getParts(); if (count($parts) === 1) { $name = $parts[0]; - if (isset($varianceByName[$name])) { - $variance = $varianceByName[$name]; + if (isset($this->varianceByName[$name])) { + $variance = $this->varianceByName[$name]; if (!in_array($variance, $allowed, true)) { - throw self::violationError( - paramName: $name, - variance: $variance, - position: $position, - hostParam: null, + $this->record( + self::violationMessage($name, $variance, $position, null), + $type->getStartLine(), ); } } @@ -282,19 +306,19 @@ private static function checkPhpType( if (is_array($args)) { foreach ($args as $arg) { if ($arg instanceof TypeRef) { - self::checkInnerTypeRef($arg, $varianceByName, $allowed, $position); + $this->checkInnerTypeRef($arg, $allowed, $position, $type->getStartLine()); } } } return; } if ($type instanceof NullableType) { - self::checkPhpType($type->type, $varianceByName, $allowed, $position); + $this->checkPhpType($type->type, $allowed, $position); return; } if ($type instanceof UnionType || $type instanceof IntersectionType) { foreach ($type->types as $inner) { - self::checkPhpType($inner, $varianceByName, $allowed, $position); + $this->checkPhpType($inner, $allowed, $position); } return; } @@ -304,37 +328,32 @@ private static function checkPhpType( } /** - * @param array $varianceByName * @param list $allowed */ - private static function checkInnerTypeRef( - TypeRef $ref, - array $varianceByName, - array $allowed, - string $position, - ): void { - if ($ref->isTypeParam && isset($varianceByName[$ref->name])) { - $variance = $varianceByName[$ref->name]; + private function checkInnerTypeRef(TypeRef $ref, array $allowed, string $position, int $line): void + { + if ($ref->isTypeParam && isset($this->varianceByName[$ref->name])) { + $variance = $this->varianceByName[$ref->name]; if (!in_array($variance, $allowed, true)) { - throw self::violationError( - paramName: $ref->name, - variance: $variance, - position: $position, - hostParam: null, - ); + $this->record(self::violationMessage($ref->name, $variance, $position, null), $line); } } foreach ($ref->args as $inner) { - self::checkInnerTypeRef($inner, $varianceByName, $allowed, $position); + $this->checkInnerTypeRef($inner, $allowed, $position, $line); } } - private static function violationError( + private function record(string $message, ?int $line): void + { + $this->violations[] = ['message' => $message, 'line' => $line]; + } + + private static function violationMessage( string $paramName, Variance $variance, string $position, ?string $hostParam, - ): RuntimeException { + ): string { $marker = match ($variance) { Variance::Covariant => '+', Variance::Contravariant => '-', @@ -343,12 +362,12 @@ private static function violationError( $context = $hostParam !== null ? sprintf(' inside the %s of generic parameter `%s`', $position, $hostParam) : sprintf(' in %s position', $position); - return new RuntimeException(sprintf( + return sprintf( 'Generic parameter `%s%s` appears%s, which is not allowed for %s variance.', $marker, $paramName, $context, $variance->value, - )); + ); } } diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index a1d9157..175a463 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -1770,22 +1770,10 @@ private function buildBoundExprNode(array $node): BoundExpr public function leaveNode(Node $node): null { - // Variance position check fires once per class definition, - // AFTER the body has been fully resolved -- so any Name nodes - // inside the body that carry nested ATTR_GENERIC_ARGS are - // visible to the validator. Rejects covariant T in input - // position, contravariant T in output, either in - // bound/default/property/constructor positions, and - // F-bounded variance (`+T : Box`). - if ($node instanceof ClassLike && $node->name !== null) { - $params = $node->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); - if (is_array($params)) { - /** @var list $params — set as a list by XphpSourceParser::resolveAndAttach. */ - if ($params !== []) { - VariancePositionValidator::assertPositions($node, $params); - } - } - } + // NB: variance-position validation no longer runs here. It moved to a + // Registry validation phase (`validateVariancePositions`) that runs over + // collected definitions, so `xphp check` can collect every variance error + // across all files in one run instead of aborting at the first parse. // @infection-ignore-all -- the instanceof chain mirrors enterNode's push; // restructuring `||` as `&&` produces a leaveNode that no longer pops the // stack for any node, but the test suite's AST shapes never re-use the diff --git a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php index ceff18d..da3f2cc 100644 --- a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php +++ b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php @@ -51,6 +51,29 @@ public function testDefaultBoundViolationIsCollectedByCheck(): void self::assertSame(Registry::CODE_DEFAULT_BOUND_VIOLATION, $diagnostics->all()[0]->code); } + public function testVariancePositionViolationIsCollectedByCheck(): void + { + // Exercises the validateVariancePositions() step of check(). + $diagnostics = $this->check('variance_violation'); + + self::assertCount(1, $diagnostics->all()); + self::assertSame(VariancePositionValidator::CODE_VARIANCE_POSITION, $diagnostics->all()[0]->code); + } + + public function testCompileStillThrowsOnVariancePositionViolation(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('not allowed for covariant variance'); + + $work = sys_get_temp_dir() . '/xphp-check-compile-' . uniqid('', true); + mkdir($work, 0o755, true); + try { + $this->buildCompiler()->compile($this->sources('variance_violation'), $this->sourceDir('variance_violation'), $work . '/dist', $work . '/cache'); + } finally { + self::rrmdir($work); + } + } + public function testMissingTypeArgumentIsCollectedByCheck(): void { $diagnostics = $this->check('missing_arg'); diff --git a/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php b/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php new file mode 100644 index 0000000..f9145d7 --- /dev/null +++ b/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php @@ -0,0 +1,134 @@ +}> + */ + public static function rejectedSources(): iterable + { + yield 'covariant in method parameter' => [ + "\n{\n public function set(T \$x): void {}\n}\n", + ['+T', 'method parameter'], + ]; + yield 'contravariant in method return' => [ + "\n{\n public function get(): T { throw new \\LogicException; }\n}\n", + ['-T', 'method return'], + ]; + yield 'covariant in mutable property' => [ + "\n{\n public T \$item;\n}\n", + ['mutable property'], + ]; + yield 'covariant in readonly property' => [ + "\n{\n public readonly T \$item;\n public function get(): T { return \$this->item; }\n}\n", + ['readonly property'], + ]; + yield 'covariant in bound' => [ + ">\n{\n public function get(): T { throw new \\LogicException; }\n}\n", + ['bound'], + ]; + yield 'covariant in constructor parameter' => [ + "\n{\n public function __construct(T \$item) {}\n public function get(): T { throw new \\LogicException; }\n}\n", + ['constructor parameter'], + ]; + yield 'covariant in nested closure parameter' => [ + "\n{\n public function emit(): array\n {\n \$f = function (T \$x) {};\n return [];\n }\n}\n", + ['nested closure/arrow parameter'], + ]; + yield 'contravariant in nested arrow return' => [ + "\n{\n public function pipe(): array\n {\n \$f = fn (): T => null;\n return [];\n }\n}\n", + ['nested closure/arrow return'], + ]; + yield 'covariant in nested generic input' => [ + "\n{\n public function set(Box \$x): void {}\n}\n", + ['+T', 'method parameter'], + ]; + yield 'contravariant in nested generic return' => [ + "\n{\n public function fetch(): Box { throw new \\LogicException; }\n}\n", + ['-T', 'method return'], + ]; + yield 'interface method signature' => [ + "\n{\n public function feed(T \$x): void;\n}\n", + ['+T'], + ]; + } + + /** + * @param list $fragments + */ + #[DataProvider('rejectedSources')] + public function testVariancePositionIsRejectedInCompileMode(string $source, array $fragments): void + { + $registry = $this->registryFor($source); + + try { + $registry->validateVariancePositions(); + self::fail('expected a variance-position violation'); + } catch (RuntimeException $e) { + foreach ($fragments as $fragment) { + self::assertStringContainsString($fragment, $e->getMessage()); + } + } + } + + public function testViolationIsCollectedWithMemberLineInCheckMode(): void + { + // Line 5 holds `public function set(T $x)`. + $source = "\n{\n public function set(T \$x): void {}\n}\n"; + $collector = new DiagnosticCollector(); + $registry = $this->registryFor($source, $collector); + + $registry->validateVariancePositions(); // must NOT throw + + self::assertCount(1, $collector->all()); + $d = $collector->all()[0]; + self::assertSame(VariancePositionValidator::CODE_VARIANCE_POSITION, $d->code); + self::assertNotNull($d->location); + self::assertSame('/T.xphp', $d->location->file); + self::assertSame(5, $d->location->line); + self::assertStringContainsString('method parameter', $d->message); + } + + public function testAllViolationsAcrossDefinitionsCollectedInOneRun(): void + { + // Two distinct templates, each with a variance violation — both reported. + $source = "\n{\n public function set(T \$x): void {}\n}\n" + . "class Consumer<-T>\n{\n public function get(): T { throw new \\LogicException; }\n}\n"; + $collector = new DiagnosticCollector(); + $registry = $this->registryFor($source, $collector); + + $registry->validateVariancePositions(); + + self::assertCount(2, $collector->all()); + foreach ($collector->all() as $d) { + self::assertSame(VariancePositionValidator::CODE_VARIANCE_POSITION, $d->code); + } + } + + private function registryFor(string $source, ?DiagnosticCollector $collector = null): Registry + { + $ast = (new XphpSourceParser((new ParserFactory())->createForHostVersion()))->parse($source); + $registry = new Registry(diagnostics: $collector); + (new RegistryCollector($registry))->collectDefinitions($ast, '/T.xphp'); + + return $registry; + } +} diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index d3848d2..2c0b42d 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -2234,116 +2234,6 @@ function id<-T>(T $x): T { return $x; } $parser->parse($source); } - public function testCovariantInInputPositionIsRejected(): void - { - $source = <<<'PHP' - -{ - public function set(T $x): void {} -} -PHP; - $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('+T'); - $this->expectExceptionMessage('method parameter'); - $parser->parse($source); - } - - public function testContravariantInOutputPositionIsRejected(): void - { - $source = <<<'PHP' - -{ - public function get(): T { throw new \LogicException; } -} -PHP; - $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('-T'); - $this->expectExceptionMessage('method return'); - $parser->parse($source); - } - - public function testCovariantInMutablePropertyIsRejected(): void - { - $source = <<<'PHP' - -{ - public T $item; -} -PHP; - $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('mutable property'); - $parser->parse($source); - } - - public function testCovariantInReadonlyPropertyIsAlsoRejected(): void - { - // PHP enforces invariant property types across `extends` chains - // regardless of `readonly`. Even though a readonly property is - // semantically "output-only", PHP's static type system rejects - // covariance on the property declaration -- so we reject it at - // declaration time to avoid an autoload-time fatal. - $source = <<<'PHP' - -{ - public readonly T $item; - public function get(): T { return $this->item; } -} -PHP; - $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('readonly property'); - $parser->parse($source); - } - - public function testCovariantInBoundIsRejected(): void - { - // F-bounded with variance: `+T : Box` rejected because T appears - // inside its own bound (an invariant position). - $source = <<<'PHP' -> -{ - public function get(): T { throw new \LogicException; } -} -PHP; - $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('bound'); - $parser->parse($source); - } - - public function testCovariantInConstructorParamIsRejected(): void - { - // xphp deviates from RFC: constructor params are invariant because - // PHP's autoload-time signature compatibility check applies to - // __construct on `Producer_Banana implements Producer_Fruit`. - $source = <<<'PHP' - -{ - public function __construct(T $item) {} - public function get(): T { throw new \LogicException; } -} -PHP; - $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('constructor parameter'); - $parser->parse($source); - } - public function testContravariantInInputPositionIsAccepted(): void { $source = <<<'PHP' @@ -2652,108 +2542,6 @@ public function testGenericClosureVarianceIsRejected(): void $parser->parse($source); } - public function testCovariantInNestedClosureParameterIsRejected(): void - { - // `+T` of the OUTER class appears in the parameter type of a nested - // CLOSURE -- the variance validator must recurse into method bodies. - // Without the recursion the inner closure's param `T $x` slips - // through, and at PHP autoload time the variance edge produces a - // signature-compat fatal. - $source = <<<'PHP' - -{ - public function emit(): array - { - $f = function (T $x) {}; - return []; - } -} -PHP; - $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('nested closure/arrow parameter'); - $parser->parse($source); - } - - public function testContravariantInNestedArrowReturnIsRejected(): void - { - // `-T` in the return position of a nested ARROW FUNCTION inside a - // contravariant Consumer's method body. - $source = <<<'PHP' - -{ - public function pipe(): array - { - $f = fn (): T => null; - return []; - } -} -PHP; - $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('nested closure/arrow return'); - $parser->parse($source); - } - - public function testCovariantInNestedGenericInputPositionIsRejected(): void - { - // `+T` inside `Box` in a method parameter position. The validator - // walks into the inner generic args attached via xphp:genericArgs; - // the +T leaf is rejected just as if it appeared directly. - $source = <<<'PHP' - -{ - public function set(Box $x): void {} -} -PHP; - $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('+T'); - $this->expectExceptionMessage('method parameter'); - $parser->parse($source); - } - - public function testContravariantInNestedGenericReturnPositionIsRejected(): void - { - // Symmetric case: `-T` inside `Box` in a method return type. - $source = <<<'PHP' - -{ - public function fetch(): Box { throw new \LogicException; } -} -PHP; - $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('-T'); - $this->expectExceptionMessage('method return'); - $parser->parse($source); - } - - public function testInterfaceMethodSignatureIsValidatedForVariance(): void - { - // Variance rules apply to interface methods too. - $source = <<<'PHP' - -{ - public function feed(T $x): void; -} -PHP; - $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('+T'); - $parser->parse($source); - } - /** * @template TNode of Node * @param array $ast diff --git a/test/fixture/check/variance_violation/source/Producer.xphp b/test/fixture/check/variance_violation/source/Producer.xphp new file mode 100644 index 0000000..fa98c77 --- /dev/null +++ b/test/fixture/check/variance_violation/source/Producer.xphp @@ -0,0 +1,13 @@ + +{ + public function set(T $value): void + { + } +} From 90450e85cc00ce72ff00cfde91d4a28370e9a9c7 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 17 Jun 2026 22:13:34 +0000 Subject: [PATCH 005/114] refactor(check): make inner-variance composition collectable 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) --- src/Transpiler/Monomorphize/Compiler.php | 10 +- .../Monomorphize/InnerVarianceValidator.php | 309 +++++++++++++++ src/Transpiler/Monomorphize/Registry.php | 356 ++---------------- .../VariancePositionValidator.php | 15 +- .../Monomorphize/CheckPassIntegrationTest.php | 15 + .../RegistryInnerVarianceTest.php | 124 +++++- .../inner_variance/source/Container.xphp | 13 + .../check/inner_variance/source/P.xphp | 16 + 8 files changed, 519 insertions(+), 339 deletions(-) create mode 100644 src/Transpiler/Monomorphize/InnerVarianceValidator.php create mode 100644 test/fixture/check/inner_variance/source/Container.xphp create mode 100644 test/fixture/check/inner_variance/source/P.xphp diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 0f5bff8..59396af 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -86,13 +86,13 @@ public function compile( // bound/default/property/constructor invariance, F-bounded variance). Moved // here from the parser so all definitions are present and `xphp check` can // collect across files; compile-mode still throws on the first violation. - $registry->validateVariancePositions(); + $variancePositionFlagged = $registry->validateVariancePositions(); // Inner-template variance composition: every template's variance // markers are known by now, so cases the parse-time validator // couldn't catch (e.g. `class P<+T> { f(): Container }` where // Container's slot is invariant) fail here BEFORE instantiations // amplify the error. - $registry->validateInnerVariance(); + $registry->validateInnerVariance($variancePositionFlagged); foreach ($astPerFile as $filepath => $ast) { $collector->collectInstantiations($ast, $filepath); } @@ -232,10 +232,8 @@ public function check(FilepathArray $sources): DiagnosticCollector $collector->collectDefinitions($ast, $filepath); } $registry->validateDefaultsAgainstBounds(); - $registry->validateVariancePositions(); - // NB: validateInnerVariance() is not wired in here yet — it still throws - // (not collectable until the inner-variance step), so calling it in check-mode - // would abort on the first violation. Added once it accepts the collector. + $variancePositionFlagged = $registry->validateVariancePositions(); + $registry->validateInnerVariance($variancePositionFlagged); foreach ($astPerFile as $filepath => $ast) { $collector->collectInstantiations($ast, $filepath); } diff --git a/src/Transpiler/Monomorphize/InnerVarianceValidator.php b/src/Transpiler/Monomorphize/InnerVarianceValidator.php new file mode 100644 index 0000000..19d4e24 --- /dev/null +++ b/src/Transpiler/Monomorphize/InnerVarianceValidator.php @@ -0,0 +1,309 @@ + { f(): Container }` where `Container`'s slot is + * invariant), the effective variance at that position is the composition of the + * outer position and the inner slot's variance. This catches cases the + * position validator can't, because the effective variance only resolves once + * the inner template's slot variances are known. + * + * Runs over collected definitions (`Registry::validateInnerVariance`). With a + * DiagnosticCollector it gathers every violation in the definition (each located + * at the offending member) and continues; without one it throws the first — + * byte-identical to the previous behavior. + */ +final class InnerVarianceValidator +{ + /** Stable diagnostic code for an inner-variance composition violation. */ + public const CODE_INNER_VARIANCE = 'xphp.inner_variance'; + + /** @var array */ + private array $definitions; + + /** @var array */ + private array $varianceMap; + + /** @var list */ + private array $violations = []; + + /** + * @param array $definitions + * @param array $varianceMap + */ + private function __construct(array $definitions, array $varianceMap) + { + $this->definitions = $definitions; + $this->varianceMap = $varianceMap; + } + + /** + * @param array $definitions All definitions, for inner-slot lookup. + */ + public static function assertComposition( + GenericDefinition $definition, + array $definitions, + ?DiagnosticCollector $diagnostics = null, + ?string $file = null, + ): void { + $varianceMap = self::buildVarianceMap($definition->typeParams); + // @infection-ignore-all -- optimization only: with an empty variance map every leaf + // check is a no-op (no name is in the map), so removing this guard walks the body + // pointlessly but produces the same (empty) result. + if ($varianceMap === []) { + return; + } + + $validator = new self($definitions, $varianceMap); + $validator->collect($definition); + if ($validator->violations === []) { + return; + } + + if ($diagnostics === null) { + throw new RuntimeException($validator->violations[0]['message']); + } + + foreach ($validator->violations as $violation) { + $location = ($violation['line'] !== null && $file !== null) + ? new SourceLocation($file, $violation['line']) + : null; + $diagnostics->add(new Diagnostic( + Severity::Error, + self::CODE_INNER_VARIANCE, + $violation['message'], + $location, + )); + } + } + + private function collect(GenericDefinition $definition): void + { + $label = $definition->templateShortName; + $declarationLine = $definition->templateAst->getStartLine(); + + foreach ($definition->templateAst->getMethods() as $method) { + $isCtor = $method->name->toLowerString() === '__construct'; + foreach ($method->params as $param) { + // Constructor params (promoted or not) get Invariant outer + // position -- PHP's class-compat rules enforce invariance on + // ctor signatures regardless of param flavor. `getProperties()` + // below skips promoted ones (they're `Param`, not `Property`), + // so each promoted property is walked exactly once. + $outerPos = $isCtor ? Variance::Invariant : Variance::Contravariant; + if ($param->type !== null) { + $this->walkPhpType($param->type, $outerPos, $label, null, null); + } + } + if ($method->returnType !== null) { + $this->walkPhpType($method->returnType, Variance::Covariant, $label, null, null); + } + } + foreach ($definition->templateAst->getProperties() as $prop) { + if ($prop->type !== null) { + $this->walkPhpType($prop->type, Variance::Invariant, $label, null, null); + } + } + foreach ($definition->typeParams as $typeParam) { + if ($typeParam->bound !== null) { + $this->walkBoundExpr($typeParam->bound, $label, $declarationLine); + } + if ($typeParam->default !== null) { + $this->walkTypeRef($typeParam->default, Variance::Invariant, $label, null, null, $declarationLine); + } + } + } + + /** + * @infection-ignore-all -- semantic-equivalent mutants in this walker: + * fall-through returns for unhandled node kinds, `??`-suppressed null-safe + * slot lookups, and Union/Intersection branch swaps (both recurse the same). + */ + private function walkPhpType( + Node $type, + Variance $position, + string $outerLabel, + ?string $innerLabel, + ?int $innerSlot, + ): void { + if ($type instanceof Identifier) { + return; + } + if ($type instanceof Name) { + $parts = $type->getParts(); + if (count($parts) === 1 && isset($this->varianceMap[$parts[0]])) { + $this->assertLeaf($parts[0], $this->varianceMap[$parts[0]], $position, $outerLabel, $innerLabel, $innerSlot, $type->getStartLine()); + } + $args = $type->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + if (is_array($args)) { + $innerFqn = $type->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); + $innerDef = is_string($innerFqn) + ? ($this->definitions[ltrim($innerFqn, '\\')] ?? null) + : null; + $nextInnerLabel = $innerDef !== null ? $innerDef->templateShortName : $type->toString(); + foreach ($args as $i => $arg) { + if (!$arg instanceof TypeRef) { + continue; + } + $slotVariance = $innerDef?->typeParams[$i]->variance ?? Variance::Invariant; + $this->walkTypeRef($arg, self::compose($position, $slotVariance), $outerLabel, $nextInnerLabel, $i, $type->getStartLine()); + } + } + return; + } + if ($type instanceof NullableType) { + $this->walkPhpType($type->type, $position, $outerLabel, $innerLabel, $innerSlot); + return; + } + if ($type instanceof UnionType || $type instanceof IntersectionType) { + foreach ($type->types as $sub) { + $this->walkPhpType($sub, $position, $outerLabel, $innerLabel, $innerSlot); + } + return; + } + if ($type instanceof ComplexType) { + return; + } + } + + /** + * @infection-ignore-all -- same equivalence rationale as `walkPhpType`. + */ + private function walkTypeRef( + TypeRef $ref, + Variance $position, + string $outerLabel, + ?string $innerLabel, + ?int $innerSlot, + ?int $line, + ): void { + if ($ref->isScalar) { + return; + } + if ($ref->isTypeParam && isset($this->varianceMap[$ref->name])) { + $this->assertLeaf($ref->name, $this->varianceMap[$ref->name], $position, $outerLabel, $innerLabel, $innerSlot, $line); + } + if ($ref->args === []) { + return; + } + $innerDef = $this->definitions[ltrim($ref->name, '\\')] ?? null; + $nextInnerLabel = $innerDef !== null ? $innerDef->templateShortName : $ref->name; + foreach ($ref->args as $i => $sub) { + $slotVariance = $innerDef?->typeParams[$i]->variance ?? Variance::Invariant; + $this->walkTypeRef($sub, self::compose($position, $slotVariance), $outerLabel, $nextInnerLabel, $i, $line); + } + } + + /** + * @infection-ignore-all -- BoundUnion / BoundIntersection share the same + * `operands` walk; the InstanceOf_ / LogicalOr mutants on the discriminator + * are observably identical for any non-Leaf bound expression. + */ + private function walkBoundExpr(BoundExpr $expr, string $outerLabel, ?int $line): void + { + if ($expr instanceof BoundLeaf) { + $this->walkTypeRef($expr->type, Variance::Invariant, $outerLabel, null, null, $line); + return; + } + if ($expr instanceof BoundUnion || $expr instanceof BoundIntersection) { + foreach ($expr->operands as $operand) { + $this->walkBoundExpr($operand, $outerLabel, $line); + } + } + } + + /** + * @infection-ignore-all -- the `Variance::Invariant => ''` arm of the + * sigil-builder `match` is unreachable: Invariant declared variance passes + * every allowed-list, so this records nothing before the sigil is built. + */ + private function assertLeaf( + string $paramName, + Variance $declared, + Variance $effective, + string $outerLabel, + ?string $innerLabel, + ?int $innerSlot, + ?int $line, + ): void { + $allowed = match ($effective) { + Variance::Invariant => [Variance::Invariant], + Variance::Covariant => [Variance::Invariant, Variance::Covariant], + Variance::Contravariant => [Variance::Invariant, Variance::Contravariant], + }; + if (in_array($declared, $allowed, true)) { + return; + } + // $declared is Covariant or Contravariant at this point — the Invariant + // case passes every allowed-list and early-returns above. + $sigil = $declared === Variance::Covariant ? '+' : '-'; + $where = $innerLabel !== null + ? sprintf(' (via slot %d of %s)', $innerSlot, $innerLabel) + : ''; + $this->violations[] = [ + 'message' => sprintf( + 'Variance violation in template %s: type-parameter %s%s appears in %s-only position%s.', + $outerLabel, + $sigil, + $paramName, + $effective->value, + $where, + ), + 'line' => $line, + ]; + } + + private static function compose(Variance $position, Variance $innerSlot): Variance + { + return match ($innerSlot) { + Variance::Invariant => Variance::Invariant, + Variance::Covariant => $position, + Variance::Contravariant => match ($position) { + Variance::Covariant => Variance::Contravariant, + Variance::Contravariant => Variance::Covariant, + Variance::Invariant => Variance::Invariant, + }, + }; + } + + /** + * @param list $typeParams + * @return array + * + * @infection-ignore-all -- FalseValue mutator on `$hasVariance = false` is + * observably identical: for all-Invariant templates, walking is a no-op + * (Invariant is allowed at every effective position), so the "skip the + * walk" optimization isn't testable. + */ + private static function buildVarianceMap(array $typeParams): array + { + $hasVariance = false; + $map = []; + foreach ($typeParams as $tp) { + $map[$tp->name] = $tp->variance; + if ($tp->variance !== Variance::Invariant) { + $hasVariance = true; + } + } + return $hasVariance ? $map : []; + } +} diff --git a/src/Transpiler/Monomorphize/Registry.php b/src/Transpiler/Monomorphize/Registry.php index 29e7688..daa028d 100644 --- a/src/Transpiler/Monomorphize/Registry.php +++ b/src/Transpiler/Monomorphize/Registry.php @@ -374,357 +374,57 @@ private static function defaultBoundViolationMessage( ); } - /** - * Inner-template variance composition pass. Runs after `collectDefinitions` - * but before `collectInstantiations`, so every template's variance markers - * are known and a bad declaration fails before any padded instantiation - * amplifies the error. - * - * The parse-time validator at `VariancePositionValidator` already rejects - * direct misuses like `class P<+T> { function f(T $x): void }` (T as param - * with covariance). It also recurses into `xphp:genericArgs` but propagates - * the SAME outer allowed-list -- which is wrong: when T appears as the i-th - * arg of an inner template `Container` whose X is invariant, T's - * effective position is *invariant* regardless of the outer position. - * - * This pass tightens the verdict whenever the inner template is in the - * registry, applying the composition rule: - * - * compose(V_outer_position, V_inner_slot): - * V_inner == Invariant -> Invariant (inner forces strict) - * V_inner == Covariant -> V_outer (transparent) - * V_inner == Contravariant -> flip(V_outer) (covariant <-> contravariant) - * - * The leaf check is "T's declared variance must be in the allowed-list for - * the effective position": - * - * allowed_for(Invariant) = {Invariant} - * allowed_for(Covariant) = {Invariant, Covariant} - * allowed_for(Contravariant) = {Invariant, Contravariant} - * - * Conservative-unknown: when the inner template isn't in the registry - * (vendor classes, in-progress files), treat its slots as Invariant. - * Sound (rejects more than necessary); users with vendor templates can - * either register them or remove variance markers on the outer template. - * - * @infection-ignore-all -- surviving mutants in this method, the walkers, - * and assertLeaf are all semantic equivalents: - * - `strtolower((string) $method->name)` -- PHP method names are - * case-insensitive at dispatch, but PhpParser stores them as - * written. No fixture uses an uppercased `__CONSTRUCT`, so the - * `strtolower` mutator survives without observable difference. - * - Fall-through `return` removals on `Identifier`, post-`Name`, - * post-`NullableType` -- the next branch checks `instanceof X` and - * fails for the prior type, so removing the `return` is a no-op. - * - `$x?->prop ?? $default` -- PHP 8.4's `??` suppresses property-on-null - * errors, so the NullSafe mutator (`?->` -> `->`) is observably - * identical to the original. - * - InstanceOf_ / LogicalOr swaps on `Union||Intersection` -- both - * `->types`/`->operands` branches walk the same way; for inputs - * that aren't either, the prior `Name`/`BoundLeaf` branches already - * returned. - * - LogicalAnd in `$ref->isTypeParam && isset($map[$ref->name])` -- - * no fixture creates a stray type-param ref outside the variance - * map, so the OR variant produces the same accept/reject decision. - * - MatchArmRemoval on `Variance::Invariant => ''` in the sigil - * builder -- Invariant declared never reaches the throw (Invariant - * passes every allowed-list), so the arm is observably unreachable. - */ /** * Variance-position check over every collected definition (moved out of the parser so * `xphp check` can collect all violations across files in one run). Delegates to * {@see VariancePositionValidator}: with this Registry's collector it gathers diagnostics * at each offending member; without one (compile) it throws the first violation. */ - public function validateVariancePositions(): void + /** + * @return list Template FQNs that had at least one position violation, so the + * inner-variance pass can skip them (the issue is already reported) — mirroring + * compile-mode, where the position check fails fast before inner-variance runs. + */ + public function validateVariancePositions(): array { - foreach ($this->definitions as $definition) { - VariancePositionValidator::assertPositions( + $flagged = []; + foreach ($this->definitions as $templateFqn => $definition) { + $hadViolation = VariancePositionValidator::assertPositions( $definition->templateAst, $definition->typeParams, $this->diagnostics, $definition->sourceFile, ); - } - } - - public function validateInnerVariance(): void - { - foreach ($this->definitions as $definition) { - $varianceMap = self::buildVarianceMap($definition->typeParams); - if ($varianceMap === []) { - continue; - } - $label = $definition->templateShortName; - foreach ($definition->templateAst->getMethods() as $method) { - $isCtor = strtolower((string) $method->name) === '__construct'; - foreach ($method->params as $param) { - // Constructor params (promoted or not) get Invariant outer - // position -- PHP's class-compat rules enforce invariance on - // ctor signatures regardless of param flavor. `getProperties()` - // below skips promoted ones (they're `Param`, not `Property`), - // so each promoted property is walked exactly once. - $outerPos = $isCtor ? Variance::Invariant : Variance::Contravariant; - if ($param->type !== null) { - $this->walkPhpType($param->type, $varianceMap, $outerPos, $label, null, null); - } - } - if ($method->returnType !== null) { - $this->walkPhpType( - $method->returnType, - $varianceMap, - Variance::Covariant, - $label, - null, - null, - ); - } - } - foreach ($definition->templateAst->getProperties() as $prop) { - if ($prop->type !== null) { - $this->walkPhpType( - $prop->type, - $varianceMap, - Variance::Invariant, - $label, - null, - null, - ); - } - } - foreach ($definition->typeParams as $typeParam) { - if ($typeParam->bound !== null) { - $this->walkBoundExpr($typeParam->bound, $varianceMap, $label); - } - if ($typeParam->default !== null) { - $this->walkTypeRef( - $typeParam->default, - $varianceMap, - Variance::Invariant, - $label, - null, - null, - ); - } + if ($hadViolation) { + $flagged[] = $templateFqn; } } - } - /** - * @param list $typeParams - * @return array - * - * @infection-ignore-all -- FalseValue mutator on `$hasVariance = false` - * is observably identical: for all-Invariant templates, walking is a - * no-op (Invariant is allowed at every effective position), so the - * "skip the walk" optimization isn't testable. - */ - private static function buildVarianceMap(array $typeParams): array - { - $hasVariance = false; - $map = []; - foreach ($typeParams as $tp) { - $map[$tp->name] = $tp->variance; - if ($tp->variance !== Variance::Invariant) { - $hasVariance = true; - } - } - return $hasVariance ? $map : []; + return $flagged; } /** - * @param array $varianceMap - * - * @infection-ignore-all -- see `validateInnerVariance` docblock for the - * catalog of semantic-equivalent mutants in this walker (fall-through - * returns, `??`-suppressed null-safe ops, Union/Intersection swaps). + * Inner-template variance composition check over every collected definition (delegates to + * {@see InnerVarianceValidator}). With this Registry's collector it gathers every violation + * (each located at the offending member); without one (compile) it throws the first. */ - private function walkPhpType( - Node $type, - array $varianceMap, - Variance $position, - string $outerLabel, - ?string $innerLabel, - ?int $innerSlot, - ): void { - if ($type instanceof Identifier) { - return; - } - if ($type instanceof Name) { - $parts = $type->getParts(); - if (count($parts) === 1 && isset($varianceMap[$parts[0]])) { - self::assertLeaf( - $parts[0], - $varianceMap[$parts[0]], - $position, - $outerLabel, - $innerLabel, - $innerSlot, - ); - } - $args = $type->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); - if (is_array($args)) { - $innerFqn = $type->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); - $innerDef = is_string($innerFqn) - ? ($this->definitions[ltrim($innerFqn, '\\')] ?? null) - : null; - $nextInnerLabel = $innerDef !== null ? $innerDef->templateShortName : $type->toString(); - foreach ($args as $i => $arg) { - if (!$arg instanceof TypeRef) { - continue; - } - $slotVariance = $innerDef?->typeParams[$i]->variance ?? Variance::Invariant; - $this->walkTypeRef( - $arg, - $varianceMap, - self::compose($position, $slotVariance), - $outerLabel, - $nextInnerLabel, - $i, - ); - } - } - return; - } - if ($type instanceof NullableType) { - $this->walkPhpType($type->type, $varianceMap, $position, $outerLabel, $innerLabel, $innerSlot); - return; - } - if ($type instanceof UnionType || $type instanceof IntersectionType) { - foreach ($type->types as $sub) { - $this->walkPhpType($sub, $varianceMap, $position, $outerLabel, $innerLabel, $innerSlot); - } - return; - } - if ($type instanceof ComplexType) { - return; - } - } - - /** - * @param array $varianceMap - * - * @infection-ignore-all -- same equivalence rationale as `walkPhpType`. - */ - private function walkTypeRef( - TypeRef $ref, - array $varianceMap, - Variance $position, - string $outerLabel, - ?string $innerLabel, - ?int $innerSlot, - ): void { - if ($ref->isScalar) { - return; - } - if ($ref->isTypeParam && isset($varianceMap[$ref->name])) { - self::assertLeaf( - $ref->name, - $varianceMap[$ref->name], - $position, - $outerLabel, - $innerLabel, - $innerSlot, - ); - } - if ($ref->args === []) { - return; - } - $innerDef = $this->definitions[ltrim($ref->name, '\\')] ?? null; - $nextInnerLabel = $innerDef !== null ? $innerDef->templateShortName : $ref->name; - foreach ($ref->args as $i => $sub) { - $slotVariance = $innerDef?->typeParams[$i]->variance ?? Variance::Invariant; - $this->walkTypeRef( - $sub, - $varianceMap, - self::compose($position, $slotVariance), - $outerLabel, - $nextInnerLabel, - $i, - ); - } - } - /** - * @param array $varianceMap - * - * @infection-ignore-all -- BoundUnion / BoundIntersection share the same - * `operands` walk; the InstanceOf_ / LogicalOr mutants on the discriminator - * are observably identical for any non-Leaf bound expression. + * @param list $skipTemplateFqns Definitions already flagged by the position check; + * skipped here so the same `+T`/`-T` misuse isn't reported by both passes. */ - private function walkBoundExpr( - BoundExpr $expr, - array $varianceMap, - string $outerLabel, - ): void { - if ($expr instanceof BoundLeaf) { - $this->walkTypeRef( - $expr->type, - $varianceMap, - Variance::Invariant, - $outerLabel, - null, - null, - ); - return; - } - if ($expr instanceof BoundUnion || $expr instanceof BoundIntersection) { - foreach ($expr->operands as $operand) { - $this->walkBoundExpr($operand, $varianceMap, $outerLabel); - } - } - } - - private static function compose(Variance $position, Variance $innerSlot): Variance + public function validateInnerVariance(array $skipTemplateFqns = []): void { - return match ($innerSlot) { - Variance::Invariant => Variance::Invariant, - Variance::Covariant => $position, - Variance::Contravariant => match ($position) { - Variance::Covariant => Variance::Contravariant, - Variance::Contravariant => Variance::Covariant, - Variance::Invariant => Variance::Invariant, - }, - }; - } - - /** - * @infection-ignore-all -- the `Variance::Invariant => ''` arm of the - * sigil-builder `match` is unreachable: Invariant declared variance - * passes every allowed-list, so this method early-returns before the - * sigil construction. MatchArmRemoval on that arm is observably - * identical. - */ - private static function assertLeaf( - string $paramName, - Variance $declared, - Variance $effective, - string $outerLabel, - ?string $innerLabel, - ?int $innerSlot, - ): void { - $allowed = match ($effective) { - Variance::Invariant => [Variance::Invariant], - Variance::Covariant => [Variance::Invariant, Variance::Covariant], - Variance::Contravariant => [Variance::Invariant, Variance::Contravariant], - }; - if (in_array($declared, $allowed, true)) { - return; + foreach ($this->definitions as $templateFqn => $definition) { + if (in_array($templateFqn, $skipTemplateFqns, true)) { + continue; + } + InnerVarianceValidator::assertComposition( + $definition, + $this->definitions, + $this->diagnostics, + $definition->sourceFile, + ); } - // $declared is Covariant or Contravariant at this point — the Invariant - // case passes every allowed-list and early-returns above. - $sigil = $declared === Variance::Covariant ? '+' : '-'; - $where = $innerLabel !== null - ? sprintf(' (via slot %d of %s)', $innerSlot, $innerLabel) - : ''; - throw new RuntimeException(sprintf( - 'Variance violation in template %s: type-parameter %s%s appears in %s-only position%s.', - $outerLabel, - $sigil, - $paramName, - $effective->value, - $where, - )); } /** diff --git a/src/Transpiler/Monomorphize/VariancePositionValidator.php b/src/Transpiler/Monomorphize/VariancePositionValidator.php index 6e1abfc..5d62c9a 100644 --- a/src/Transpiler/Monomorphize/VariancePositionValidator.php +++ b/src/Transpiler/Monomorphize/VariancePositionValidator.php @@ -80,27 +80,34 @@ private function __construct(array $varianceByName) /** * @param list $params + * @return bool True iff at least one violation was found (in check-mode; compile-mode + * throws before returning). Lets the caller skip the inner-variance pass for a + * definition already flagged here, avoiding a double report of the same issue. */ public static function assertPositions( ClassLike $node, array $params, ?DiagnosticCollector $diagnostics = null, ?string $file = null, - ): void { + ): bool { $varianceByName = []; foreach ($params as $param) { if ($param->variance !== Variance::Invariant) { $varianceByName[$param->name] = $param->variance; } } + // @infection-ignore-all FalseValue -- returning true here (no variance markers) would + // only add this definition to the caller's "flagged" set, which merely skips the + // inner-variance pass for it; but a no-variance definition is already a no-op in + // inner-variance (empty variance map), so flagging it changes nothing observable. if ($varianceByName === []) { - return; + return false; } $validator = new self($varianceByName); $validator->collect($node, $params); if ($validator->violations === []) { - return; + return false; } if ($diagnostics === null) { @@ -119,6 +126,8 @@ public static function assertPositions( $location, )); } + + return true; } /** diff --git a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php index da3f2cc..711b9ac 100644 --- a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php +++ b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php @@ -74,6 +74,21 @@ public function testCompileStillThrowsOnVariancePositionViolation(): void } } + public function testInnerVarianceViolationIsCollectedByCheck(): void + { + // Composition case the position check misses → only inner-variance reports it, + // and the position check does NOT also flag it (no double report). + $diagnostics = $this->check('inner_variance'); + + self::assertCount(1, $diagnostics->all()); + $d = $diagnostics->all()[0]; + self::assertSame(InnerVarianceValidator::CODE_INNER_VARIANCE, $d->code); + // Located at the `Container` return type in P.xphp (line 12). + self::assertNotNull($d->location); + self::assertStringEndsWith('P.xphp', $d->location->file); + self::assertSame(12, $d->location->line); + } + public function testMissingTypeArgumentIsCollectedByCheck(): void { $diagnostics = $this->check('missing_arg'); diff --git a/test/Transpiler/Monomorphize/RegistryInnerVarianceTest.php b/test/Transpiler/Monomorphize/RegistryInnerVarianceTest.php index f4ef36b..ae56f61 100644 --- a/test/Transpiler/Monomorphize/RegistryInnerVarianceTest.php +++ b/test/Transpiler/Monomorphize/RegistryInnerVarianceTest.php @@ -18,6 +18,7 @@ use PhpParser\Node\VarLikeIdentifier; use PHPUnit\Framework\TestCase; use RuntimeException; +use XPHP\Diagnostics\DiagnosticCollector; /** * Tests `Registry::validateInnerVariance` -- the pass that composes outer @@ -31,6 +32,125 @@ */ final class RegistryInnerVarianceTest extends TestCase { + public function testPositionFlaggedDefinitionIsSkippedButLaterDefinitionsStillRun(): void + { + // P (direct +T-in-param) is flagged by the position check and recorded FIRST; + // Q (composition violation) is recorded AFTER. Inner-variance must skip P (already + // reported) yet still report Q — i.e. it must `continue` past P, not `break`. + $collector = new DiagnosticCollector(); + $registry = $this->registryWith([ + $this->makeDefinition( + 'App\\P', + 'P', + [new TypeParam('T', variance: Variance::Covariant)], + $this->classWithMethod('P', 'set', [new Param(new \PhpParser\Node\Expr\Variable('x'), type: new Name(['T']))], new Identifier('void')), + ), + $this->makeDefinition('App\\Container', 'Container', [new TypeParam('X')], new Class_(new Identifier('Container'))), + $this->makeDefinition( + 'App\\Q', + 'Q', + [new TypeParam('T', variance: Variance::Covariant)], + $this->classWithMethod('Q', 'f', [], $this->genericName('App\\Container', [new TypeRef('T', isTypeParam: true)])), + ), + ], $collector); + + $flagged = $registry->validateVariancePositions(); + $registry->validateInnerVariance($flagged); + + self::assertSame(['App\\P'], $flagged); + self::assertCount(2, $collector->all()); + $codes = array_map(static fn ($d): string => $d->code, $collector->all()); + self::assertContains(VariancePositionValidator::CODE_VARIANCE_POSITION, $codes); + self::assertContains(InnerVarianceValidator::CODE_INNER_VARIANCE, $codes); + } + + public function testAllPositionFlaggedDefinitionsAreSkippedByInnerVariance(): void + { + // Two direct +T-in-param violations: both flagged by the position check, so the + // inner-variance pass must skip BOTH (the full flagged list, not a truncation). + $collector = new DiagnosticCollector(); + $registry = $this->registryWith([ + $this->makeDefinition( + 'App\\P', + 'P', + [new TypeParam('T', variance: Variance::Covariant)], + $this->classWithMethod('P', 'set', [new Param(new \PhpParser\Node\Expr\Variable('x'), type: new Name(['T']))], new Identifier('void')), + ), + $this->makeDefinition( + 'App\\R', + 'R', + [new TypeParam('T', variance: Variance::Covariant)], + $this->classWithMethod('R', 'set', [new Param(new \PhpParser\Node\Expr\Variable('x'), type: new Name(['T']))], new Identifier('void')), + ), + ], $collector); + + $flagged = $registry->validateVariancePositions(); + $registry->validateInnerVariance($flagged); + + self::assertSame(['App\\P', 'App\\R'], $flagged); + self::assertCount(2, $collector->all()); + foreach ($collector->all() as $d) { + self::assertSame(VariancePositionValidator::CODE_VARIANCE_POSITION, $d->code); + } + } + + public function testNullFileProducesNullLocationWithoutError(): void + { + // Defensive: with no file, the diagnostic still emits (null location) and must not + // attempt to build a SourceLocation from a null file. + $registry = $this->registryWith([ + $this->makeDefinition('App\\Container', 'Container', [new TypeParam('X')], new Class_(new Identifier('Container'))), + $this->makeDefinition( + 'App\\P', + 'P', + [new TypeParam('T', variance: Variance::Covariant)], + $this->classWithMethod( + 'P', + 'f', + [], + $this->genericName('App\\Container', [new TypeRef('T', isTypeParam: true)]), + ), + ), + ]); + $container = $registry->definition('App\\Container'); + $p = $registry->definition('App\\P'); + self::assertNotNull($container); + self::assertNotNull($p); + + $collector = new DiagnosticCollector(); + InnerVarianceValidator::assertComposition($p, ['App\\Container' => $container, 'App\\P' => $p], $collector, null); + + self::assertCount(1, $collector->all()); + self::assertNull($collector->all()[0]->location); + } + + public function testCollectModeGathersInnerVarianceDiagnosticInsteadOfThrowing(): void + { + // Same composition violation as the throw-mode test, but with a collector: + // it must be reported as a Diagnostic (not thrown), so `xphp check` continues. + $collector = new DiagnosticCollector(); + $registry = $this->registryWith([ + $this->makeDefinition('App\\Container', 'Container', [new TypeParam('X')], new Class_(new Identifier('Container'))), + $this->makeDefinition( + 'App\\P', + 'P', + [new TypeParam('T', variance: Variance::Covariant)], + $this->classWithMethod( + 'P', + 'f', + [], + $this->genericName('App\\Container', [new TypeRef('T', isTypeParam: true)]), + ), + ), + ], $collector); + + $registry->validateInnerVariance(); + + self::assertCount(1, $collector->all()); + self::assertSame(InnerVarianceValidator::CODE_INNER_VARIANCE, $collector->all()[0]->code); + self::assertStringContainsString('Variance violation in template P', $collector->all()[0]->message); + } + public function testCovariantOuterInInvariantInnerSlotIsRejected(): void { // class Container {} // X is Invariant @@ -904,9 +1024,9 @@ public function testPropertyInvariantPositionRejectsCovariantOuter(): void /** * @param list $defs */ - private function registryWith(array $defs): Registry + private function registryWith(array $defs, ?DiagnosticCollector $collector = null): Registry { - $registry = new Registry(); + $registry = new Registry(diagnostics: $collector); foreach ($defs as $def) { $registry->recordDefinition( $def->fqn, diff --git a/test/fixture/check/inner_variance/source/Container.xphp b/test/fixture/check/inner_variance/source/Container.xphp new file mode 100644 index 0000000..886c9c3 --- /dev/null +++ b/test/fixture/check/inner_variance/source/Container.xphp @@ -0,0 +1,13 @@ + +{ + public function __construct(public T $value) + { + } +} diff --git a/test/fixture/check/inner_variance/source/P.xphp b/test/fixture/check/inner_variance/source/P.xphp new file mode 100644 index 0000000..74b4a61 --- /dev/null +++ b/test/fixture/check/inner_variance/source/P.xphp @@ -0,0 +1,16 @@ + +{ + public function f(): Container + { + throw new \LogicException(); + } +} From c4e68156c04c0ce6be9162a3469c273edc6d2d57 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 17 Jun 2026 22:19:38 +0000 Subject: [PATCH 006/114] refactor(check): run variance-position before defaults; tidy docblocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/Transpiler/Monomorphize/Compiler.php | 6 ++++-- src/Transpiler/Monomorphize/Registry.php | 6 ++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 59396af..051a876 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -81,12 +81,14 @@ public function compile( // bad declaration like `class Box` fails BEFORE any // padded instantiation is recorded), then collect instantiations -- including // bare `new Foo;` shapes for templates whose every param has a default. - $registry->validateDefaultsAgainstBounds(); // Variance-position rules (covariant T in input, contravariant T in output, // bound/default/property/constructor invariance, F-bounded variance). Moved // here from the parser so all definitions are present and `xphp check` can // collect across files; compile-mode still throws on the first violation. + // Runs BEFORE the defaults-vs-bounds check so that, when a class has both, + // the variance error surfaces first (the order it surfaced at parse time). $variancePositionFlagged = $registry->validateVariancePositions(); + $registry->validateDefaultsAgainstBounds(); // Inner-template variance composition: every template's variance // markers are known by now, so cases the parse-time validator // couldn't catch (e.g. `class P<+T> { f(): Container }` where @@ -231,8 +233,8 @@ public function check(FilepathArray $sources): DiagnosticCollector foreach ($astPerFile as $filepath => $ast) { $collector->collectDefinitions($ast, $filepath); } - $registry->validateDefaultsAgainstBounds(); $variancePositionFlagged = $registry->validateVariancePositions(); + $registry->validateDefaultsAgainstBounds(); $registry->validateInnerVariance($variancePositionFlagged); foreach ($astPerFile as $filepath => $ast) { $collector->collectInstantiations($ast, $filepath); diff --git a/src/Transpiler/Monomorphize/Registry.php b/src/Transpiler/Monomorphize/Registry.php index daa028d..6db6e53 100644 --- a/src/Transpiler/Monomorphize/Registry.php +++ b/src/Transpiler/Monomorphize/Registry.php @@ -379,8 +379,7 @@ private static function defaultBoundViolationMessage( * `xphp check` can collect all violations across files in one run). Delegates to * {@see VariancePositionValidator}: with this Registry's collector it gathers diagnostics * at each offending member; without one (compile) it throws the first violation. - */ - /** + * * @return list Template FQNs that had at least one position violation, so the * inner-variance pass can skip them (the issue is already reported) — mirroring * compile-mode, where the position check fails fast before inner-variance runs. @@ -407,8 +406,7 @@ public function validateVariancePositions(): array * Inner-template variance composition check over every collected definition (delegates to * {@see InnerVarianceValidator}). With this Registry's collector it gathers every violation * (each located at the offending member); without one (compile) it throws the first. - */ - /** + * * @param list $skipTemplateFqns Definitions already flagged by the position check; * skipped here so the same `+T`/`-T` misuse isn't reported by both passes. */ From 7e856870ce4c8863b522733beb81a690fb2563e3 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 17 Jun 2026 22:23:02 +0000 Subject: [PATCH 007/114] feat(check): add Text, JSON, and GitHub diagnostic renderers 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) --- .../Renderer/DiagnosticRenderer.php | 19 +++ src/Diagnostics/Renderer/GithubRenderer.php | 56 ++++++++ src/Diagnostics/Renderer/JsonRenderer.php | 50 +++++++ src/Diagnostics/Renderer/TextRenderer.php | 53 +++++++ test/Diagnostics/Renderer/RendererTest.php | 129 ++++++++++++++++++ 5 files changed, 307 insertions(+) create mode 100644 src/Diagnostics/Renderer/DiagnosticRenderer.php create mode 100644 src/Diagnostics/Renderer/GithubRenderer.php create mode 100644 src/Diagnostics/Renderer/JsonRenderer.php create mode 100644 src/Diagnostics/Renderer/TextRenderer.php create mode 100644 test/Diagnostics/Renderer/RendererTest.php diff --git a/src/Diagnostics/Renderer/DiagnosticRenderer.php b/src/Diagnostics/Renderer/DiagnosticRenderer.php new file mode 100644 index 0000000..7732f7d --- /dev/null +++ b/src/Diagnostics/Renderer/DiagnosticRenderer.php @@ -0,0 +1,19 @@ + $diagnostics + */ + public function render(array $diagnostics): string; +} diff --git a/src/Diagnostics/Renderer/GithubRenderer.php b/src/Diagnostics/Renderer/GithubRenderer.php new file mode 100644 index 0000000..76b9151 --- /dev/null +++ b/src/Diagnostics/Renderer/GithubRenderer.php @@ -0,0 +1,56 @@ + `::warning`, `Notice` -> `::notice`. Message and property values + * are escaped per GitHub's workflow-command rules (newlines/commas/colons). + */ +final class GithubRenderer implements DiagnosticRenderer +{ + public function render(array $diagnostics): string + { + $lines = []; + foreach ($diagnostics as $d) { + $command = match ($d->severity) { + Severity::Error => 'error', + Severity::Warning => 'warning', + Severity::Notice => 'notice', + }; + + $props = []; + if ($d->location !== null) { + $props[] = 'file=' . self::escapeProperty($d->location->file); + $props[] = 'line=' . $d->location->line; + if ($d->location->column !== null) { + $props[] = 'col=' . $d->location->column; + } + } + + $prefix = $props === [] ? '::' . $command : '::' . $command . ' ' . implode(',', $props); + $lines[] = $prefix . '::' . self::escapeData($d->message); + } + + return $lines === [] ? '' : implode(PHP_EOL, $lines) . PHP_EOL; + } + + private static function escapeData(string $value): string + { + return str_replace(['%', "\r", "\n"], ['%25', '%0D', '%0A'], $value); + } + + private static function escapeProperty(string $value): string + { + return str_replace(['%', "\r", "\n", ':', ','], ['%25', '%0D', '%0A', '%3A', '%2C'], $value); + } +} diff --git a/src/Diagnostics/Renderer/JsonRenderer.php b/src/Diagnostics/Renderer/JsonRenderer.php new file mode 100644 index 0000000..34fb44f --- /dev/null +++ b/src/Diagnostics/Renderer/JsonRenderer.php @@ -0,0 +1,50 @@ + $d->severity->value, + 'code' => $d->code, + 'message' => $d->message, + 'source' => $d->source->value, + 'triggeredBy' => $d->triggeredBy, + 'file' => $d->location?->file, + 'line' => $d->location?->line, + 'column' => $d->location?->column, + ]; + } + + return json_encode( + ['diagnostics' => $items], + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR, + ) . PHP_EOL; + } +} diff --git a/src/Diagnostics/Renderer/TextRenderer.php b/src/Diagnostics/Renderer/TextRenderer.php new file mode 100644 index 0000000..ac81b8b --- /dev/null +++ b/src/Diagnostics/Renderer/TextRenderer.php @@ -0,0 +1,53 @@ + + * + * The message may be multi-line; the location/code sit on their own line so + * multi-line messages stay readable. + */ +final class TextRenderer implements DiagnosticRenderer +{ + public function render(array $diagnostics): string + { + if ($diagnostics === []) { + return 'No problems found.' . PHP_EOL; + } + + $blocks = []; + foreach ($diagnostics as $d) { + $lines = [sprintf('%s: %s', $d->severity->value, $d->message)]; + $lines[] = ' ' . $this->locationSuffix($d); + if ($d->triggeredBy !== null) { + $lines[] = ' triggered by ' . $d->triggeredBy; + } + $blocks[] = implode(PHP_EOL, $lines); + } + + return implode(PHP_EOL . PHP_EOL, $blocks) . PHP_EOL; + } + + private function locationSuffix(Diagnostic $d): string + { + $code = '[' . $d->code . ']'; + if ($d->location === null) { + return $code; + } + $at = $d->location->file . ':' . $d->location->line; + if ($d->location->column !== null) { + $at .= ':' . $d->location->column; + } + + return 'at ' . $at . ' ' . $code; + } +} diff --git a/test/Diagnostics/Renderer/RendererTest.php b/test/Diagnostics/Renderer/RendererTest.php new file mode 100644 index 0000000..30a359a --- /dev/null +++ b/test/Diagnostics/Renderer/RendererTest.php @@ -0,0 +1,129 @@ + */ + private function sample(): array + { + return [ + new Diagnostic( + Severity::Error, + 'xphp.bound_violation', + 'bad bound', + new SourceLocation('/src/Box.xphp', 7, 3), + ), + new Diagnostic( + Severity::Warning, + 'phpstan.return', + 'maybe', + null, + 'App\\Box', + DiagnosticSource::PhpStan, + ), + ]; + } + + public function testTextEmpty(): void + { + self::assertSame('No problems found.' . PHP_EOL, (new TextRenderer())->render([])); + } + + public function testTextRender(): void + { + $expected = implode(PHP_EOL, [ + 'error: bad bound', + ' at /src/Box.xphp:7:3 [xphp.bound_violation]', + '', + 'warning: maybe', + ' [phpstan.return]', + ' triggered by App\\Box', + ]) . PHP_EOL; + + self::assertSame($expected, (new TextRenderer())->render($this->sample())); + } + + public function testTextOmitsColumnWhenAbsent(): void + { + $d = [new Diagnostic(Severity::Error, 'c', 'm', new SourceLocation('/a.xphp', 4))]; + self::assertStringContainsString('at /a.xphp:4 [c]', (new TextRenderer())->render($d)); + } + + public function testJsonContract(): void + { + $out = (new JsonRenderer())->render($this->sample()); + // Pin the raw formatting: pretty-printed (space after key) and slashes unescaped. + self::assertStringContainsString('"file": "/src/Box.xphp"', $out); + /** @var array{diagnostics: list>} $decoded */ + $decoded = json_decode($out, true, flags: JSON_THROW_ON_ERROR); + + self::assertSame([ + 'diagnostics' => [ + [ + 'severity' => 'error', + 'code' => 'xphp.bound_violation', + 'message' => 'bad bound', + 'source' => 'xphp', + 'triggeredBy' => null, + 'file' => '/src/Box.xphp', + 'line' => 7, + 'column' => 3, + ], + [ + 'severity' => 'warning', + 'code' => 'phpstan.return', + 'message' => 'maybe', + 'source' => 'phpstan', + 'triggeredBy' => 'App\\Box', + 'file' => null, + 'line' => null, + 'column' => null, + ], + ], + ], $decoded); + } + + public function testJsonEmpty(): void + { + self::assertSame('{' . PHP_EOL . ' "diagnostics": []' . PHP_EOL . '}' . PHP_EOL, (new JsonRenderer())->render([])); + } + + public function testGithubRender(): void + { + $expected = implode(PHP_EOL, [ + '::error file=/src/Box.xphp,line=7,col=3::bad bound', + '::warning::maybe', + ]) . PHP_EOL; + + self::assertSame($expected, (new GithubRenderer())->render($this->sample())); + } + + public function testGithubEscapesMessageAndProperties(): void + { + $d = [new Diagnostic( + Severity::Notice, + 'c', + "line one\nline two", + new SourceLocation('/weird,name:x.xphp', 1), + )]; + + self::assertSame( + '::notice file=/weird%2Cname%3Ax.xphp,line=1::line one%0Aline two' . PHP_EOL, + (new GithubRenderer())->render($d), + ); + } + + public function testGithubEmpty(): void + { + self::assertSame('', (new GithubRenderer())->render([])); + } +} From b077a4cf787b340004d3d19105af44470bad03b6 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 17 Jun 2026 22:25:20 +0000 Subject: [PATCH 008/114] test(check): pin GitHub renderer percent/carriage-return escaping Co-Authored-By: Claude Opus 4.8 (1M context) --- test/Diagnostics/Renderer/RendererTest.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/Diagnostics/Renderer/RendererTest.php b/test/Diagnostics/Renderer/RendererTest.php index 30a359a..18cbb97 100644 --- a/test/Diagnostics/Renderer/RendererTest.php +++ b/test/Diagnostics/Renderer/RendererTest.php @@ -126,4 +126,12 @@ public function testGithubEmpty(): void { self::assertSame('', (new GithubRenderer())->render([])); } + + public function testGithubEscapesPercentAndCarriageReturnInMessage(): void + { + $d = [new Diagnostic(Severity::Error, 'c', "50%\rdone")]; + + // `%` must be escaped first (to %25), then `\r` to %0D — no double-escaping. + self::assertSame('::error::50%25%0Ddone' . PHP_EOL, (new GithubRenderer())->render($d)); + } } From 3034f64b0d89a46cedb6e67c977ae1d4daffcba4 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 17 Jun 2026 22:35:27 +0000 Subject: [PATCH 009/114] feat(check): add the `xphp check` command with per-file parse resilience Wire a CheckCommand (`xphp check [--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) --- src/Console/ApplicationConsole.php | 25 ++-- src/Console/Command/CheckCommand.php | 82 +++++++++++++ src/Transpiler/Monomorphize/Compiler.php | 43 ++++++- test/Console/CheckCommandTest.php | 112 ++++++++++++++++++ .../fixture/check/parse_error/source/Bag.xphp | 12 ++ .../check/parse_error/source/Broken.xphp | 11 ++ .../parse_error/source/ClosureVariance.xphp | 11 ++ .../fixture/check/parse_error/source/Use.xphp | 8 ++ 8 files changed, 289 insertions(+), 15 deletions(-) create mode 100644 src/Console/Command/CheckCommand.php create mode 100644 test/Console/CheckCommandTest.php create mode 100644 test/fixture/check/parse_error/source/Bag.xphp create mode 100644 test/fixture/check/parse_error/source/Broken.xphp create mode 100644 test/fixture/check/parse_error/source/ClosureVariance.xphp create mode 100644 test/fixture/check/parse_error/source/Use.xphp diff --git a/src/Console/ApplicationConsole.php b/src/Console/ApplicationConsole.php index bed4708..9daf74f 100644 --- a/src/Console/ApplicationConsole.php +++ b/src/Console/ApplicationConsole.php @@ -7,6 +7,7 @@ use PhpParser\ParserFactory; use PhpParser\PrettyPrinter\Standard as StandardPrinter; use Symfony\Component\Console\Application; +use XPHP\Console\Command\CheckCommand; use XPHP\Console\Command\CompileCommand; use XPHP\FileSystem\FileFinder; use XPHP\FileSystem\FileReader; @@ -35,17 +36,17 @@ public function __construct( $phpParser = (new ParserFactory())->createForHostVersion(); $printer = new StandardPrinter(); - $this->addCommand(new CompileCommand( - $fileFinder, - new Compiler( - $fileReader, - $fileWriter, - new XphpSourceParser($phpParser), - new Specializer(), - new SpecializedClassGenerator($printer, $fileWriter), - $printer, - $hashLength, - ), - )); + $compiler = new Compiler( + $fileReader, + $fileWriter, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $fileWriter), + $printer, + $hashLength, + ); + + $this->addCommand(new CompileCommand($fileFinder, $compiler)); + $this->addCommand(new CheckCommand($fileFinder, $compiler)); } } diff --git a/src/Console/Command/CheckCommand.php b/src/Console/Command/CheckCommand.php new file mode 100644 index 0000000..a88e8b7 --- /dev/null +++ b/src/Console/Command/CheckCommand.php @@ -0,0 +1,82 @@ + [--format=text|json|github]` + * + * Validates the generic code without emitting any output, reporting every + * diagnostic in one run. Exit codes: 0 = clean, 1 = at least one error-severity + * diagnostic, 2 = operational failure (bad source dir or unknown format). + */ +#[AsCommand('check', 'Validate generic .xphp code and report diagnostics without emitting output')] +final class CheckCommand extends Command +{ + public function __construct( + private readonly FileFinder $fileFinder, + private readonly Compiler $compiler, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addArgument('source', InputArgument::REQUIRED, 'Directory containing .xphp source files') + ->addOption('format', null, InputOption::VALUE_REQUIRED, 'Output format: text, json, or github', 'text'); + } + + protected function execute( + InputInterface $input, + OutputInterface $output, + ): int { + // @infection-ignore-all CastString -- a REQUIRED argument is always a string; + // the cast is defensive for getArgument()'s mixed return, so removing it is equivalent. + $sourceDir = (string) $input->getArgument('source'); + if (!is_dir($sourceDir)) { + $output->writeln("Source directory not found: {$sourceDir}"); + return self::INVALID; + } + + $renderer = $this->rendererFor((string) $input->getOption('format')); + if ($renderer === null) { + $output->writeln('Unknown format (expected: text, json, github)'); + return self::INVALID; + } + + $sources = $this->fileFinder + ->find($sourceDir) + ->filter(static fn (string $filepath): bool => str_ends_with($filepath, '.xphp')); + + $diagnostics = $this->compiler->check($sources); + + $output->write($renderer->render($diagnostics->all())); + + return $diagnostics->hasErrors() ? self::FAILURE : self::SUCCESS; + } + + private function rendererFor(string $format): ?DiagnosticRenderer + { + return match ($format) { + 'text' => new TextRenderer(), + 'json' => new JsonRenderer(), + 'github' => new GithubRenderer(), + default => null, + }; + } +} diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 051a876..8681290 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -4,9 +4,13 @@ namespace XPHP\Transpiler\Monomorphize; +use PhpParser\Error as PhpParserError; use PhpParser\PrettyPrinter\Standard as StandardPrinter; use RuntimeException; +use XPHP\Diagnostics\Diagnostic; use XPHP\Diagnostics\DiagnosticCollector; +use XPHP\Diagnostics\Severity; +use XPHP\Diagnostics\SourceLocation; use XPHP\FileSystem\FileReader; use XPHP\FileSystem\FileWriter; use XPHP\FileSystem\FilepathArray; @@ -32,6 +36,9 @@ { public const MAX_SPECIALIZATION_DEPTH = 16; + /** Diagnostic code for a file that failed to parse during `xphp check`. */ + public const CODE_PARSE_ERROR = 'xphp.parse_error'; + public function __construct( private FileReader $fileReader, private FileWriter $fileWriter, @@ -220,13 +227,43 @@ public function compile( * * Scope note: method/function/closure-level generic checks (GenericMethodCompiler, Phase 1a * of compile()) are intentionally NOT run here — they remain fail-fast and are not yet part - * of the check gate. Variance checks are wired in a later step. + * of the check gate. + * + * Per-file resilience: a file that fails to parse is reported as a diagnostic and skipped, + * so the remaining files are still checked (unlike compile(), which fails fast). */ public function check(FilepathArray $sources): DiagnosticCollector { - $astPerFile = $this->parseAll($sources); - $hierarchy = TypeHierarchy::fromAstPerFile($astPerFile); $diagnostics = new DiagnosticCollector(); + $astPerFile = []; + foreach ($sources->filepaths as $filepath) { + try { + $astPerFile[$filepath] = $this->sourceParser->parse($this->fileReader->read($filepath)); + } catch (PhpParserError $e) { + $line = $e->getStartLine(); + $diagnostics->add(new Diagnostic( + Severity::Error, + self::CODE_PARSE_ERROR, + $e->getMessage(), + // @infection-ignore-all GreaterThan/IncrementInteger/DecrementInteger -- a real + // PHP syntax error always reports a line >= 1, so the `> 0` boundary (and its + // `?: 1` fallback) is defensive and unobservable; the happy-path line is pinned + // by CheckCommandTest (Broken.xphp -> line 11). + new SourceLocation($filepath, $line > 0 ? $line : 1), + )); + } catch (RuntimeException $e) { + // xphp-specific parse-time rejections (e.g. variance markers on methods) — these + // carry no line, so the diagnostic points at the file (line 1). + $diagnostics->add(new Diagnostic( + Severity::Error, + self::CODE_PARSE_ERROR, + $e->getMessage(), + new SourceLocation($filepath, 1), + )); + } + } + + $hierarchy = TypeHierarchy::fromAstPerFile($astPerFile); $registry = new Registry($this->hashLength, $hierarchy, $diagnostics); $collector = new RegistryCollector($registry); diff --git a/test/Console/CheckCommandTest.php b/test/Console/CheckCommandTest.php new file mode 100644 index 0000000..b047e5a --- /dev/null +++ b/test/Console/CheckCommandTest.php @@ -0,0 +1,112 @@ +tester(); + $exit = $tester->execute(['source' => $this->fixtureDir('clean')]); + + self::assertSame(0, $exit); + self::assertStringContainsString('No problems found', $tester->getDisplay()); + } + + public function testGenericErrorsExitOne(): void + { + $tester = $this->tester(); + $exit = $tester->execute(['source' => $this->fixtureDir('multi_error')]); + + self::assertSame(1, $exit); + self::assertStringContainsString('bound violated', $tester->getDisplay()); + } + + public function testParseErrorIsReportedButOtherFilesStillChecked(): void + { + $tester = $this->tester(); + $exit = $tester->execute(['source' => $this->fixtureDir('parse_error'), '--format' => 'json']); + + self::assertSame(1, $exit); + /** @var array{diagnostics: list} $decoded */ + $decoded = json_decode($tester->getDisplay(), true, flags: JSON_THROW_ON_ERROR); + + $byFile = []; + foreach ($decoded['diagnostics'] as $d) { + $byFile[basename($d['file'])] = $d; + } + + // A PHP syntax error: reported with its real line. + self::assertSame(Compiler::CODE_PARSE_ERROR, $byFile['Broken.xphp']['code']); + self::assertSame(11, $byFile['Broken.xphp']['line']); + // An xphp-specific parse rejection (no line): reported at line 1. + self::assertSame(Compiler::CODE_PARSE_ERROR, $byFile['ClosureVariance.xphp']['code']); + self::assertSame(1, $byFile['ClosureVariance.xphp']['line']); + // A VALID file is still checked despite the two unparseable files. + self::assertSame('xphp.bound_violation', $byFile['Use.xphp']['code']); + } + + public function testMissingSourceDirectoryExitsTwoWithMessage(): void + { + $tester = $this->tester(); + $exit = $tester->execute(['source' => sys_get_temp_dir() . '/xphp-does-not-exist-' . uniqid('', true)]); + + self::assertSame(2, $exit); + self::assertStringContainsString('Source directory not found', $tester->getDisplay()); + } + + public function testUnknownFormatExitsTwoWithMessage(): void + { + $tester = $this->tester(); + $exit = $tester->execute(['source' => $this->fixtureDir('clean'), '--format' => 'xml']); + + self::assertSame(2, $exit); + self::assertStringContainsString('Unknown format', $tester->getDisplay()); + } + + public function testGithubFormatEmitsAnnotations(): void + { + $tester = $this->tester(); + $tester->execute(['source' => $this->fixtureDir('multi_error'), '--format' => 'github']); + + self::assertStringContainsString('::error file=', $tester->getDisplay()); + } + + private function tester(): CommandTester + { + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + $compiler = new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + + return new CommandTester(new CheckCommand(new NativeFileFinder(), $compiler)); + } + + private function fixtureDir(string $fixture): string + { + return realpath(__DIR__ . '/../fixture/check/' . $fixture . '/source') + ?: throw new RuntimeException("Fixture missing: {$fixture}"); + } +} diff --git a/test/fixture/check/parse_error/source/Bag.xphp b/test/fixture/check/parse_error/source/Bag.xphp new file mode 100644 index 0000000..c50ddb6 --- /dev/null +++ b/test/fixture/check/parse_error/source/Bag.xphp @@ -0,0 +1,12 @@ + +{ + public function __construct(public T $value) + { + } +} diff --git a/test/fixture/check/parse_error/source/Broken.xphp b/test/fixture/check/parse_error/source/Broken.xphp new file mode 100644 index 0000000..43ddf26 --- /dev/null +++ b/test/fixture/check/parse_error/source/Broken.xphp @@ -0,0 +1,11 @@ +(T $x): T { + return $x; +}; diff --git a/test/fixture/check/parse_error/source/Use.xphp b/test/fixture/check/parse_error/source/Use.xphp new file mode 100644 index 0000000..1534b1b --- /dev/null +++ b/test/fixture/check/parse_error/source/Use.xphp @@ -0,0 +1,8 @@ +(1); From 21f855b2600ad75c0b43e1a815ac45bdf8094709 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 17 Jun 2026 22:40:18 +0000 Subject: [PATCH 010/114] refactor(check): read source files outside the parse try/catch 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) --- src/Transpiler/Monomorphize/Compiler.php | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 8681290..153a8f0 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -237,23 +237,27 @@ public function check(FilepathArray $sources): DiagnosticCollector $diagnostics = new DiagnosticCollector(); $astPerFile = []; foreach ($sources->filepaths as $filepath) { + // Read OUTSIDE the try so an I/O failure surfaces as itself, not a mislabeled + // "parse error" — only parsing is treated as a per-file, recoverable diagnostic. + $content = $this->fileReader->read($filepath); try { - $astPerFile[$filepath] = $this->sourceParser->parse($this->fileReader->read($filepath)); + $astPerFile[$filepath] = $this->sourceParser->parse($content); } catch (PhpParserError $e) { $line = $e->getStartLine(); $diagnostics->add(new Diagnostic( Severity::Error, self::CODE_PARSE_ERROR, $e->getMessage(), - // @infection-ignore-all GreaterThan/IncrementInteger/DecrementInteger -- a real - // PHP syntax error always reports a line >= 1, so the `> 0` boundary (and its - // `?: 1` fallback) is defensive and unobservable; the happy-path line is pinned - // by CheckCommandTest (Broken.xphp -> line 11). + // @infection-ignore-all GreaterThan/IncrementInteger/DecrementInteger -- nikic + // emits either a real line (>= 1) or the sentinel -1 for position-less errors; + // every `> 0` boundary variant routes -1 to the same `?: 1` fallback, so the + // mutants are equivalent. The real-line path is pinned by CheckCommandTest + // (Broken.xphp -> line 11). new SourceLocation($filepath, $line > 0 ? $line : 1), )); } catch (RuntimeException $e) { - // xphp-specific parse-time rejections (e.g. variance markers on methods) — these - // carry no line, so the diagnostic points at the file (line 1). + // xphp-specific parse-time rejections from the parser (e.g. variance markers on + // methods) — these carry no line, so the diagnostic points at the file (line 1). $diagnostics->add(new Diagnostic( Severity::Error, self::CODE_PARSE_ERROR, From d552742723160facb3c857b4c4391bfc9e901953 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 17 Jun 2026 22:43:02 +0000 Subject: [PATCH 011/114] docs: document the `xphp check` diagnostics gate 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) --- README.md | 9 +++++++++ docs/errors.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/README.md b/README.md index 1d4ee90..2f0f90c 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,15 @@ compile. `dist/` holds your rewritten code; `.xphp-cache/Generated/` holds the specialized classes. Both can be gitignored and rebuilt in CI. +To validate generics without emitting anything — a CI gate that reports +every bound/variance/etc. problem with a `file:line`: + +```bash +vendor/bin/xphp check src # exit 1 if any error; --format=text|json|github +``` + +See [Errors and diagnostics](docs/errors.md#xphp-check--validate-without-emitting). + ## See also - [Getting started](docs/getting-started.md) -- full walkthrough including PSR-4 details, runtime semantics, and what the generated PHP looks like diff --git a/docs/errors.md b/docs/errors.md index 1e14d3c..8bf0982 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -4,6 +4,55 @@ Every compile-time rejection from xphp lists the error message verbatim below, paired with the docs section that explains the constraint. Search this page for the text your compile output shows. +## `xphp check` — validate without emitting + +`vendor/bin/xphp check ` validates your generic `.xphp` +code and reports **every** problem below as a structured diagnostic — +each with a `file:line` location — instead of aborting on the first. +It writes no output (no `dist/`, no cache); it's a pure gate, ideal +for CI. + +```bash +vendor/bin/xphp check src +``` + +Output formats via `--format`: + +| Format | Use | +|--------|-----| +| `text` (default) | human-readable, one block per problem | +| `json` | machine-readable; stable `{ "diagnostics": [ … ] }` shape for tooling | +| `github` | GitHub Actions annotations (`::error file=…,line=…::…`) so problems show inline on a PR | + +Exit codes: **0** (clean), **1** (at least one error), **2** (bad +source directory or unknown `--format`). A file that fails to parse is +reported and skipped, so the rest of the tree is still checked in the +same run. + +### Diagnostic codes + +The `json` and `github` formats tag each diagnostic with a stable code: + +| Code | Meaning | +|------|---------| +| `xphp.bound_violation` | a concrete type argument doesn't satisfy its parameter's bound | +| `xphp.default_bound_violation` | a parameter's default doesn't satisfy its own bound | +| `xphp.missing_type_argument` | a required type argument was omitted and has no default | +| `xphp.variance_position` | a `+T`/`-T` parameter appears in a position its variance forbids | +| `xphp.inner_variance` | variance is violated through another generic's slot (composition) | +| `xphp.undefined_template` | a generic was instantiated but never declared | +| `xphp.parse_error` | the file isn't valid PHP after the generic strip pass | + +> Scope: `xphp check` covers the class/interface/trait-level generic +> checks on this page. Method-, function-, and closure-level generic +> errors remain `xphp compile` failures for now. + +In CI (GitHub Actions), one step gates the build and annotates the diff: + +```yaml +- run: vendor/bin/xphp check src --format=github +``` + ## Quick index | If the message contains... | Read | From c34b0a1b17e3b3d6a0514f63a4e7d4c7fa4368ec Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 18 Jun 2026 06:11:56 +0000 Subject: [PATCH 012/114] docs: clarify that `xphp check` is not yet a substitute for `xphp compile` 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) --- README.md | 5 ++++- docs/errors.md | 11 ++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2f0f90c..7f5c728 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,10 @@ every bound/variance/etc. problem with a `file:line`: vendor/bin/xphp check src # exit 1 if any error; --format=text|json|github ``` -See [Errors and diagnostics](docs/errors.md#xphp-check--validate-without-emitting). +`check` is a fast pre-flight gate, not yet a full substitute for `compile` +(method/function/closure-level generic errors surface only at compile) — keep +`compile` in your build. See +[Errors and diagnostics](docs/errors.md#xphp-check--validate-without-emitting). ## See also diff --git a/docs/errors.md b/docs/errors.md index 8bf0982..9ad6558 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -43,9 +43,14 @@ The `json` and `github` formats tag each diagnostic with a stable code: | `xphp.undefined_template` | a generic was instantiated but never declared | | `xphp.parse_error` | the file isn't valid PHP after the generic strip pass | -> Scope: `xphp check` covers the class/interface/trait-level generic -> checks on this page. Method-, function-, and closure-level generic -> errors remain `xphp compile` failures for now. +> **Scope — `xphp check` is not yet a substitute for `xphp compile`.** +> `check` covers the class/interface/trait-level generic checks on this +> page. It does **not** yet run the method-, function-, and +> closure-level generic checks (those still surface only as `xphp +> compile` failures), and by design it never runs the specialization +> loop, so the depth-cap and hash-collision guards are out of scope too. +> A clean `check` therefore does **not** guarantee a clean `compile` — +> keep `xphp compile` in your build pipeline. In CI (GitHub Actions), one step gates the build and annotates the diff: From 078f9d98171b8edc91806171d98cc4aa58a4e3de Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 18 Jun 2026 09:30:13 +0000 Subject: [PATCH 013/114] feat(check): collect method/function/closure-level generic errors 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) --- README.md | 5 +- docs/errors.md | 16 +-- src/Transpiler/Monomorphize/Compiler.php | 16 ++- .../Monomorphize/GenericMethodCompiler.php | 109 +++++++++++++++--- .../Monomorphize/CheckPassIntegrationTest.php | 98 ++++++++++++++++ .../check/closure_this_capture/source/C.xphp | 19 +++ .../duplicate_generic_function/source/a.xphp | 15 +++ .../duplicate_generic_function/source/b.xphp | 15 +++ .../generic_function_bound/source/Use.xphp | 8 ++ .../generic_function_bound/source/funcs.xphp | 10 ++ .../source/Use.xphp | 9 ++ .../source/Util.xphp | 12 ++ .../source/Producer.xphp | 13 +++ .../method_and_class_errors/source/Use.xphp | 8 ++ .../method_and_class_errors/source/funcs.xphp | 10 ++ 15 files changed, 335 insertions(+), 28 deletions(-) create mode 100644 test/fixture/check/closure_this_capture/source/C.xphp create mode 100644 test/fixture/check/duplicate_generic_function/source/a.xphp create mode 100644 test/fixture/check/duplicate_generic_function/source/b.xphp create mode 100644 test/fixture/check/generic_function_bound/source/Use.xphp create mode 100644 test/fixture/check/generic_function_bound/source/funcs.xphp create mode 100644 test/fixture/check/generic_method_missing_arg/source/Use.xphp create mode 100644 test/fixture/check/generic_method_missing_arg/source/Util.xphp create mode 100644 test/fixture/check/method_and_class_errors/source/Producer.xphp create mode 100644 test/fixture/check/method_and_class_errors/source/Use.xphp create mode 100644 test/fixture/check/method_and_class_errors/source/funcs.xphp diff --git a/README.md b/README.md index 7f5c728..a01c61f 100644 --- a/README.md +++ b/README.md @@ -150,9 +150,8 @@ every bound/variance/etc. problem with a `file:line`: vendor/bin/xphp check src # exit 1 if any error; --format=text|json|github ``` -`check` is a fast pre-flight gate, not yet a full substitute for `compile` -(method/function/closure-level generic errors surface only at compile) — keep -`compile` in your build. See +`check` runs all of xphp's generic validation (the specialization-loop guards +aside); you still run `compile` to emit the PHP. See [Errors and diagnostics](docs/errors.md#xphp-check--validate-without-emitting). ## See also diff --git a/docs/errors.md b/docs/errors.md index 9ad6558..790a8a5 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -43,14 +43,14 @@ The `json` and `github` formats tag each diagnostic with a stable code: | `xphp.undefined_template` | a generic was instantiated but never declared | | `xphp.parse_error` | the file isn't valid PHP after the generic strip pass | -> **Scope — `xphp check` is not yet a substitute for `xphp compile`.** -> `check` covers the class/interface/trait-level generic checks on this -> page. It does **not** yet run the method-, function-, and -> closure-level generic checks (those still surface only as `xphp -> compile` failures), and by design it never runs the specialization -> loop, so the depth-cap and hash-collision guards are out of scope too. -> A clean `check` therefore does **not** guarantee a clean `compile` — -> keep `xphp compile` in your build pipeline. +> **Scope.** `xphp check` runs every generic *validation* check `xphp compile` +> does — class/interface/trait-level **and** method/function/closure-level +> (bounds, variance, defaults, missing/duplicate generics, unsupported closures). +> By design it does not run the specialization loop or emit code, so the two +> runaway/config guards — the nested-specialization **depth cap** and the +> **hash-collision** check — surface only at `xphp compile` (they aren't type +> errors). You still run `xphp compile` to produce the PHP; `check` is the fast +> validation gate in front of it. In CI (GitHub Actions), one step gates the build and annotates the diff: diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 153a8f0..d3d1005 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -225,9 +225,9 @@ public function compile( * the first. Stops after validation — it never specializes or emits, so a partially-invalid * registry never reaches the fixed-point loop. Returns the collected diagnostics. * - * Scope note: method/function/closure-level generic checks (GenericMethodCompiler, Phase 1a - * of compile()) are intentionally NOT run here — they remain fail-fast and are not yet part - * of the check gate. + * Includes method/function/closure-level generic checks: GenericMethodCompiler runs in + * validate-only mode (`emit: false`) so it collects bound / missing-arg / duplicate-function / + * closure-rejection diagnostics without specializing or emitting. * * Per-file resilience: a file that fails to parse is reported as a diagnostic and skipped, * so the remaining files are still checked (unlike compile(), which fails fast). @@ -282,6 +282,16 @@ public function check(FilepathArray $sources): DiagnosticCollector } $registry->collectUndefinedTemplates($diagnostics); + // Method/function/closure-level generic checks: run GenericMethodCompiler in validate-only + // mode (emit: false) so it collects bound / missing-arg / duplicate-function / closure-rejection + // diagnostics without specializing or mutating the (discarded) AST. + // @infection-ignore-all FalseValue -- `emit: true` is observably equivalent here: the + // validation calls (which produce the diagnostics) run in BOTH modes; `emit` only governs + // append/strip/finalize side-effects on `$astPerFile`, which is local and discarded. So + // flipping it changes only wasted work, not the collected diagnostics. `emit: false` is the + // correct (no-wasted-work, no-mutation) choice. + (new GenericMethodCompiler($this->hashLength, $hierarchy, $diagnostics))->process($astPerFile, emit: false); + return $diagnostics; } diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 124411e..3e3d459 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -45,6 +45,10 @@ use PhpParser\NodeTraverser; use PhpParser\NodeVisitorAbstract; use RuntimeException; +use XPHP\Diagnostics\Diagnostic; +use XPHP\Diagnostics\DiagnosticCollector; +use XPHP\Diagnostics\Severity; +use XPHP\Diagnostics\SourceLocation; /** * Specializes method-scoped generics: `function NAME(...)` inside a class body, called via @@ -72,9 +76,21 @@ */ final class GenericMethodCompiler { + /** Stable diagnostic codes for method/function/closure-level generic errors collected by `check`. */ + public const CODE_DUPLICATE_GENERIC_FUNCTION = 'xphp.duplicate_generic_function'; + public const CODE_UNSUPPORTED_THIS_CAPTURE = 'xphp.closure_this_capture'; + public const CODE_UNSUPPORTED_STATIC_CLOSURE = 'xphp.static_closure'; + + /** + * @param ?DiagnosticCollector $diagnostics When null (the default — `xphp compile`), every + * method/function/closure-level generic error throws as before, byte-identical. When provided + * (by `xphp check` with `process(..., emit: false)`), each is appended as a Diagnostic and the + * pass continues, so all are reported in one run. + */ public function __construct( private readonly int $hashLength = Registry::DEFAULT_HASH_HEX_LENGTH, private readonly ?TypeHierarchy $hierarchy = null, + private readonly ?DiagnosticCollector $diagnostics = null, ) { } @@ -85,8 +101,12 @@ public function __construct( * * @param array> $astSet keyed by an arbitrary string id (filepath * or ""). The values are the top-level statements of each AST. + * @param bool $emit When true (default, compile) the pass specializes, appends, and strips + * templates as before. When false (`xphp check`) it walks for VALIDATION only — no append-flush, + * no strip, no closure-dispatcher finalize — so the (discarded) AST is left untouched and only + * diagnostics are produced. */ - public function process(array &$astSet): void + public function process(array &$astSet, bool $emit = true): void { /** @var array $methodTemplates keyed by "classFqn::methodName" */ $methodTemplates = []; @@ -112,14 +132,24 @@ public function process(array &$astSet): void // files) with both paths, matching the shape `Registry::recordDefinition` // uses for generic classes. Silently overwriting the first body — which is // the prior behavior here — costs a real refactoring footgun. - foreach ($perFileFns as $fqn => $_template) { + foreach ($perFileFns as $fqn => $duplicate) { if (isset($functionTemplates[$fqn])) { - throw new RuntimeException(sprintf( + $message = sprintf( 'Generic function template "%s" already declared (in %s); duplicate declaration in %s.', $fqn, $functionSourceByFqn[$fqn], (string) $astKey, - )); + ); + if ($this->diagnostics !== null) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + self::CODE_DUPLICATE_GENERIC_FUNCTION, + $message, + new SourceLocation((string) $astKey, $duplicate->getStartLine()), + )); + continue; + } + throw new RuntimeException($message); } } @@ -166,13 +196,22 @@ public function process(array &$astSet): void $functionNamespaceByFqn, $alreadyGenerated, $topLevelAppends, + (string) $astKey, + $emit, ); - foreach ($topLevelAppends as $specialized) { - $ast[] = $specialized; + if ($emit) { + foreach ($topLevelAppends as $specialized) { + $ast[] = $specialized; + } } } unset($ast); + // Validate-only (check) stops here: no template stripping, so the AST is left intact. + if (!$emit) { + return; + } + // Strip the original method templates from their owning classes. foreach ($methodTemplates as $key => $template) { // @infection-ignore-all — explode limit 2 vs 3: the key never contains @@ -314,13 +353,16 @@ private function rewriteCallSites( array $functionNamespaceByFqn, array &$alreadyGenerated, array &$topLevelAppends, + string $currentFile, + bool $emit, ): void { $hashLength = $this->hashLength; $hierarchy = $this->hierarchy; + $diagnostics = $this->diagnostics; // @infection-ignore-all — see rationale above the indexTemplates visitor: defensive // guards and call-shape mutations are masked by the surrounding pipeline's // type-strict invariants. End-to-end coverage from GenericMethodIntegrationTest. - $visitor = new class($methodTemplates, $classByFqn, $functionTemplates, $functionNamespaceByFqn, $alreadyGenerated, $hashLength, $hierarchy, $topLevelAppends) extends NodeVisitorAbstract { + $visitor = new class($methodTemplates, $classByFqn, $functionTemplates, $functionNamespaceByFqn, $alreadyGenerated, $hashLength, $hierarchy, $topLevelAppends, $diagnostics, $currentFile) extends NodeVisitorAbstract { private string $currentNamespace = ''; private ?Namespace_ $currentNamespaceNode = null; /** @var array alias => fqn */ @@ -460,6 +502,8 @@ public function __construct( private int $hashLength, private ?TypeHierarchy $hierarchy, public array &$topLevelAppends, + private readonly ?DiagnosticCollector $diagnostics, + private readonly string $currentFile, ) { } @@ -895,7 +939,8 @@ private function rewriteStaticCall(StaticCall $node): ?Node $args = []; } /** @var list $args — set as a list by XphpSourceParser::resolveAndAttach (or empty after the all-defaults branch above). */ - $padded = Registry::padArgsWithDefaults($params, $args, $key); + $location = new SourceLocation($this->currentFile, $node->getStartLine()); + $padded = Registry::padArgsWithDefaults($params, $args, $key, $this->diagnostics, $location); if (!self::allConcrete($padded) || count($params) !== count($padded)) { return null; } @@ -907,6 +952,8 @@ private function rewriteStaticCall(StaticCall $node): ?Node $args, $this->hierarchy, $classFqn . '::' . $methodName . '<' . self::formatArgList($args) . '>', + $this->diagnostics, + $location, ); } @@ -982,7 +1029,8 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): $args = []; } /** @var list $args — set as a list by XphpSourceParser::resolveAndAttach (or empty after the all-defaults branch above). */ - $padded = Registry::padArgsWithDefaults($params, $args, $key); + $location = new SourceLocation($this->currentFile, $node->getStartLine()); + $padded = Registry::padArgsWithDefaults($params, $args, $key, $this->diagnostics, $location); if (!self::allConcrete($padded) || count($params) !== count($padded)) { return null; } @@ -994,6 +1042,8 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): $args, $this->hierarchy, $classFqn . '::' . $methodName . '<' . self::formatArgList($args) . '>', + $this->diagnostics, + $location, ); } @@ -1121,6 +1171,8 @@ private function rewriteFuncCall(FuncCall $node): ?Node $args, $this->hierarchy, $fqn . '<' . self::formatArgList($args) . '>', + $this->diagnostics, + new SourceLocation($this->currentFile, $node->getStartLine()), ); } @@ -1204,7 +1256,7 @@ private function rewriteVariableTurbofishCall(FuncCall $node, array $args): null // A future commit can rewrite `$this->v` to a lifted // param. $flavor = $template instanceof ArrowFunction ? 'arrow' : 'closure'; - throw new RuntimeException(sprintf( + $message = sprintf( 'Generic %s `$%s::<...>(...)` captures `$this`, ' . 'which is not yet supported. Rewrite as a method ' . 'on the enclosing class, or extract the value of ' @@ -1213,15 +1265,35 @@ private function rewriteVariableTurbofishCall(FuncCall $node, array $args): null $flavor, $varName, $flavor, - )); + ); + if ($this->diagnostics !== null) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + GenericMethodCompiler::CODE_UNSUPPORTED_THIS_CAPTURE, + $message, + new SourceLocation($this->currentFile, $node->getStartLine()), + )); + return null; + } + throw new RuntimeException($message); } if ($template instanceof Closure && $template->static) { - throw new RuntimeException(sprintf( + $message = sprintf( 'Generic static closures cannot yet be specialized at ' . 'call sites. Rewrite the call site for `$%s::<...>(...)` ' . 'to use a named generic function at file scope.', $varName, - )); + ); + if ($this->diagnostics !== null) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + GenericMethodCompiler::CODE_UNSUPPORTED_STATIC_CLOSURE, + $message, + new SourceLocation($this->currentFile, $node->getStartLine()), + )); + return null; + } + throw new RuntimeException($message); } $params = $template->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); @@ -1234,7 +1306,8 @@ private function rewriteVariableTurbofishCall(FuncCall $node, array $args): null // generic closure / arrow. Padding throws when leading // required params are missing -- the throw surfaces with // a clear `Registry::padArgsWithDefaults` message. - $args = Registry::padArgsWithDefaults($params, $args, 'closure<' . $varName . '>'); + $location = new SourceLocation($this->currentFile, $node->getStartLine()); + $args = Registry::padArgsWithDefaults($params, $args, 'closure<' . $varName . '>', $this->diagnostics, $location); if (count($params) !== count($args)) { return null; } @@ -1244,6 +1317,8 @@ private function rewriteVariableTurbofishCall(FuncCall $node, array $args): null $args, $this->hierarchy, 'closure<' . self::formatArgList($args) . '>', + $this->diagnostics, + $location, ); } $context = $this->currentScopeClosureContexts[$varName] ?? null; @@ -1391,6 +1466,12 @@ private static function lastSegment(string $name): string $traverser->addVisitor($visitor); $traverser->traverse($ast); + // Validate-only (check) skips all emission: no dispatcher materialization, no buffered + // appends. The traversal above already produced the diagnostics via the call-site checks. + if (!$emit) { + return; + } + // Pass 2 of the closure-dispatcher pipeline: materialize a dispatcher // closure per recorded template, replace the original Assign's RHS, // append specialized declarations, and rewrite each collected call diff --git a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php index 711b9ac..2c1c9e9 100644 --- a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php +++ b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php @@ -107,6 +107,93 @@ public function testUndefinedTemplateIsCollectedByCheck(): void self::assertSame(Registry::CODE_UNDEFINED_TEMPLATE, $diagnostics->all()[0]->code); } + public function testGenericFunctionBoundViolationIsCollectedByCheck(): void + { + $diagnostics = $this->check('generic_function_bound'); + + self::assertCount(1, $diagnostics->all()); + $d = $diagnostics->all()[0]; + self::assertSame(Registry::CODE_BOUND_VIOLATION, $d->code); + self::assertNotNull($d->location); + self::assertStringEndsWith('Use.xphp', $d->location->file); + self::assertSame(8, $d->location->line); + } + + public function testGenericMethodMissingArgumentIsCollectedByCheck(): void + { + $diagnostics = $this->check('generic_method_missing_arg'); + + self::assertCount(1, $diagnostics->all()); + $d = $diagnostics->all()[0]; + self::assertSame(Registry::CODE_MISSING_TYPE_ARGUMENT, $d->code); + self::assertNotNull($d->location); + self::assertSame(9, $d->location->line); + } + + public function testDuplicateGenericFunctionIsCollectedByCheck(): void + { + // Two functions re-declared in the second file → BOTH duplicates collected in one run + // (the loop must `continue`, not `break`, after the first). + $diagnostics = $this->check('duplicate_generic_function'); + + self::assertCount(2, $diagnostics->all()); + foreach ($diagnostics->all() as $d) { + self::assertSame(GenericMethodCompiler::CODE_DUPLICATE_GENERIC_FUNCTION, $d->code); + self::assertNotNull($d->location); + self::assertStringEndsWith('b.xphp', $d->location->file); + } + } + + public function testThisCapturingGenericClosureIsCollectedByCheck(): void + { + $diagnostics = $this->check('closure_this_capture'); + + self::assertCount(1, $diagnostics->all()); + $d = $diagnostics->all()[0]; + self::assertSame(GenericMethodCompiler::CODE_UNSUPPORTED_THIS_CAPTURE, $d->code); + self::assertNotNull($d->location); + self::assertSame(17, $d->location->line); + } + + public function testClassAndMethodLevelErrorsAreBothCollectedInOneRun(): void + { + // Proves the validation-superset guarantee: a class-level check (variance) AND a + // method-level check (generic-function bound) both report in a single `check` run. + $diagnostics = $this->check('method_and_class_errors'); + + $codes = array_map(static fn ($d): string => $d->code, $diagnostics->all()); + self::assertContains(VariancePositionValidator::CODE_VARIANCE_POSITION, $codes); + self::assertContains(Registry::CODE_BOUND_VIOLATION, $codes); + } + + public function testCompileStillThrowsOnGenericFunctionBound(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Generic bound violated'); + $this->compileFixture('generic_function_bound'); + } + + public function testCompileStillThrowsOnGenericMethodMissingArgument(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('has no default'); + $this->compileFixture('generic_method_missing_arg'); + } + + public function testCompileStillThrowsOnDuplicateGenericFunction(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('already declared'); + $this->compileFixture('duplicate_generic_function'); + } + + public function testCompileStillThrowsOnThisCapturingGenericClosure(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('captures `$this`'); + $this->compileFixture('closure_this_capture'); + } + public function testCompileStillThrowsOnUndefinedTemplate(): void { // The same condition is a hard error in compile-mode (no collector) — confirms the @@ -123,6 +210,17 @@ public function testCompileStillThrowsOnUndefinedTemplate(): void } } + private function compileFixture(string $fixture): void + { + $work = sys_get_temp_dir() . '/xphp-check-compile-' . uniqid('', true); + mkdir($work, 0o755, true); + try { + $this->buildCompiler()->compile($this->sources($fixture), $this->sourceDir($fixture), $work . '/dist', $work . '/cache'); + } finally { + self::rrmdir($work); + } + } + private function check(string $fixture): DiagnosticCollector { return $this->buildCompiler()->check($this->sources($fixture)); diff --git a/test/fixture/check/closure_this_capture/source/C.xphp b/test/fixture/check/closure_this_capture/source/C.xphp new file mode 100644 index 0000000..d01714e --- /dev/null +++ b/test/fixture/check/closure_this_capture/source/C.xphp @@ -0,0 +1,19 @@ +(T $x): string { + return $this->name; + }; + + return $f::(1); + } +} diff --git a/test/fixture/check/duplicate_generic_function/source/a.xphp b/test/fixture/check/duplicate_generic_function/source/a.xphp new file mode 100644 index 0000000..930d23b --- /dev/null +++ b/test/fixture/check/duplicate_generic_function/source/a.xphp @@ -0,0 +1,15 @@ +(T $x): T +{ + return $x; +} + +function dup2(T $x): T +{ + return $x; +} diff --git a/test/fixture/check/duplicate_generic_function/source/b.xphp b/test/fixture/check/duplicate_generic_function/source/b.xphp new file mode 100644 index 0000000..930d23b --- /dev/null +++ b/test/fixture/check/duplicate_generic_function/source/b.xphp @@ -0,0 +1,15 @@ +(T $x): T +{ + return $x; +} + +function dup2(T $x): T +{ + return $x; +} diff --git a/test/fixture/check/generic_function_bound/source/Use.xphp b/test/fixture/check/generic_function_bound/source/Use.xphp new file mode 100644 index 0000000..738aae8 --- /dev/null +++ b/test/fixture/check/generic_function_bound/source/Use.xphp @@ -0,0 +1,8 @@ +(1); diff --git a/test/fixture/check/generic_function_bound/source/funcs.xphp b/test/fixture/check/generic_function_bound/source/funcs.xphp new file mode 100644 index 0000000..bb72049 --- /dev/null +++ b/test/fixture/check/generic_function_bound/source/funcs.xphp @@ -0,0 +1,10 @@ +(T $x): T +{ + return $x; +} diff --git a/test/fixture/check/generic_method_missing_arg/source/Use.xphp b/test/fixture/check/generic_method_missing_arg/source/Use.xphp new file mode 100644 index 0000000..cdf2ae8 --- /dev/null +++ b/test/fixture/check/generic_method_missing_arg/source/Use.xphp @@ -0,0 +1,9 @@ +pair::(1, 'x'); diff --git a/test/fixture/check/generic_method_missing_arg/source/Util.xphp b/test/fixture/check/generic_method_missing_arg/source/Util.xphp new file mode 100644 index 0000000..b498d80 --- /dev/null +++ b/test/fixture/check/generic_method_missing_arg/source/Util.xphp @@ -0,0 +1,12 @@ +(A $a, B $b): void + { + } +} diff --git a/test/fixture/check/method_and_class_errors/source/Producer.xphp b/test/fixture/check/method_and_class_errors/source/Producer.xphp new file mode 100644 index 0000000..8276ea2 --- /dev/null +++ b/test/fixture/check/method_and_class_errors/source/Producer.xphp @@ -0,0 +1,13 @@ + +{ + public function set(T $value): void + { + } +} diff --git a/test/fixture/check/method_and_class_errors/source/Use.xphp b/test/fixture/check/method_and_class_errors/source/Use.xphp new file mode 100644 index 0000000..4c54820 --- /dev/null +++ b/test/fixture/check/method_and_class_errors/source/Use.xphp @@ -0,0 +1,8 @@ +(1); diff --git a/test/fixture/check/method_and_class_errors/source/funcs.xphp b/test/fixture/check/method_and_class_errors/source/funcs.xphp new file mode 100644 index 0000000..73cafa3 --- /dev/null +++ b/test/fixture/check/method_and_class_errors/source/funcs.xphp @@ -0,0 +1,10 @@ +(T $x): T +{ + return $x; +} From 1892b73bb324cc948333cf9fff8d0cfcb6081cbd Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 18 Jun 2026 09:36:16 +0000 Subject: [PATCH 014/114] test(check): cover the static-closure collect path; document new codes 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) --- docs/errors.md | 3 +++ .../Monomorphize/GenericMethodCompiler.php | 6 ++++-- .../Monomorphize/CheckPassIntegrationTest.php | 18 ++++++++++++++++++ .../check/closure_static/source/Use.xphp | 12 ++++++++++++ 4 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 test/fixture/check/closure_static/source/Use.xphp diff --git a/docs/errors.md b/docs/errors.md index 790a8a5..6cd1122 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -41,6 +41,9 @@ The `json` and `github` formats tag each diagnostic with a stable code: | `xphp.variance_position` | a `+T`/`-T` parameter appears in a position its variance forbids | | `xphp.inner_variance` | variance is violated through another generic's slot (composition) | | `xphp.undefined_template` | a generic was instantiated but never declared | +| `xphp.duplicate_generic_function` | the same generic function is declared in two files | +| `xphp.closure_this_capture` | a generic closure/arrow used via turbofish captures `$this` (unsupported) | +| `xphp.static_closure` | a generic `static` closure used via turbofish (unsupported) | | `xphp.parse_error` | the file isn't valid PHP after the generic strip pass | > **Scope.** `xphp check` runs every generic *validation* check `xphp compile` diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 3e3d459..c9f3903 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -103,7 +103,8 @@ public function __construct( * or ""). The values are the top-level statements of each AST. * @param bool $emit When true (default, compile) the pass specializes, appends, and strips * templates as before. When false (`xphp check`) it walks for VALIDATION only — no append-flush, - * no strip, no closure-dispatcher finalize — so the (discarded) AST is left untouched and only + * no strip, no closure-dispatcher finalize. The traversal still rewrites call-site nodes on the + * (discarded) per-file AST, but templates are deep-cloned so nothing shared is mutated; only * diagnostics are produced. */ public function process(array &$astSet, bool $emit = true): void @@ -207,7 +208,8 @@ public function process(array &$astSet, bool $emit = true): void } unset($ast); - // Validate-only (check) stops here: no template stripping, so the AST is left intact. + // Validate-only (check) stops here: no template stripping or append-flush. The discarded + // per-file AST may carry the traversal's in-place call-site rewrites, but nothing shared is. if (!$emit) { return; } diff --git a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php index 2c1c9e9..8822852 100644 --- a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php +++ b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php @@ -155,6 +155,24 @@ public function testThisCapturingGenericClosureIsCollectedByCheck(): void self::assertSame(17, $d->location->line); } + public function testStaticGenericClosureIsCollectedByCheck(): void + { + $diagnostics = $this->check('closure_static'); + + self::assertCount(1, $diagnostics->all()); + $d = $diagnostics->all()[0]; + self::assertSame(GenericMethodCompiler::CODE_UNSUPPORTED_STATIC_CLOSURE, $d->code); + self::assertNotNull($d->location); + self::assertSame(12, $d->location->line); + } + + public function testCompileStillThrowsOnStaticGenericClosure(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('static closures cannot yet be specialized'); + $this->compileFixture('closure_static'); + } + public function testClassAndMethodLevelErrorsAreBothCollectedInOneRun(): void { // Proves the validation-superset guarantee: a class-level check (variance) AND a diff --git a/test/fixture/check/closure_static/source/Use.xphp b/test/fixture/check/closure_static/source/Use.xphp new file mode 100644 index 0000000..ac05560 --- /dev/null +++ b/test/fixture/check/closure_static/source/Use.xphp @@ -0,0 +1,12 @@ +(T $x): T { + return $x; +}; + +$r = $f::(1); From d008c05e13031d364d4bc3fb1d3fbe46842d9531 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 18 Jun 2026 11:11:25 +0000 Subject: [PATCH 015/114] build(phpstan): raise analysis to level 9 and fix the surfaced findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- phpstan.neon | 2 +- src/Console/Command/CheckCommand.php | 11 +++++++---- src/Console/Command/CompileCommand.php | 11 ++++++++--- src/Transpiler/Monomorphize/Specializer.php | 1 + 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index eab1b77..c158993 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,4 +1,4 @@ parameters: - level: 7 + level: 9 paths: - src diff --git a/src/Console/Command/CheckCommand.php b/src/Console/Command/CheckCommand.php index a88e8b7..308339e 100644 --- a/src/Console/Command/CheckCommand.php +++ b/src/Console/Command/CheckCommand.php @@ -45,15 +45,18 @@ protected function execute( InputInterface $input, OutputInterface $output, ): int { - // @infection-ignore-all CastString -- a REQUIRED argument is always a string; - // the cast is defensive for getArgument()'s mixed return, so removing it is equivalent. - $sourceDir = (string) $input->getArgument('source'); + // getArgument()/getOption() are typed `mixed`; these are scalar inputs (a required + // argument and an option with a string default), so they are always strings — narrow + // rather than blind-cast (PHPStan level 9 rejects casting mixed). + $sourceArg = $input->getArgument('source'); + $sourceDir = is_string($sourceArg) ? $sourceArg : ''; if (!is_dir($sourceDir)) { $output->writeln("Source directory not found: {$sourceDir}"); return self::INVALID; } - $renderer = $this->rendererFor((string) $input->getOption('format')); + $formatOption = $input->getOption('format'); + $renderer = $this->rendererFor(is_string($formatOption) ? $formatOption : ''); if ($renderer === null) { $output->writeln('Unknown format (expected: text, json, github)'); return self::INVALID; diff --git a/src/Console/Command/CompileCommand.php b/src/Console/Command/CompileCommand.php index 67bdb42..366bb5f 100644 --- a/src/Console/Command/CompileCommand.php +++ b/src/Console/Command/CompileCommand.php @@ -34,9 +34,14 @@ public function execute( InputInterface $input, OutputInterface $output, ): int { - $sourceDir = (string) $input->getArgument('source'); - $targetDir = (string) $input->getArgument('target'); - $cacheDir = (string) $input->getArgument('cache'); + // getArgument() is typed `mixed`; these are scalar args (a required one and two with + // string defaults), so they are always strings — narrow rather than blind-cast. + $sourceArg = $input->getArgument('source'); + $targetArg = $input->getArgument('target'); + $cacheArg = $input->getArgument('cache'); + $sourceDir = is_string($sourceArg) ? $sourceArg : ''; + $targetDir = is_string($targetArg) ? $targetArg : 'dist'; + $cacheDir = is_string($cacheArg) ? $cacheArg : '.xphp-cache'; if (!is_dir($sourceDir)) { $output->writeln("Source directory not found: {$sourceDir}"); diff --git a/src/Transpiler/Monomorphize/Specializer.php b/src/Transpiler/Monomorphize/Specializer.php index 1fe295f..51a9081 100644 --- a/src/Transpiler/Monomorphize/Specializer.php +++ b/src/Transpiler/Monomorphize/Specializer.php @@ -155,6 +155,7 @@ public function leaveNode(Node $node): ?Node $args = $node->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); if (is_array($args) && $args !== []) { + /** @var list $args — ATTR_GENERIC_ARGS is a TypeRef list (set by XphpSourceParser); the type-hint lets array_map infer the callback's parameter as TypeRef. */ $substituted = array_map( fn (TypeRef $a): TypeRef => Specializer::substituteTypeRef($a, $this->substitution), $args, From 984c2fda8c304a4f04ebe64299e592fea48f18f0 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 18 Jun 2026 11:35:19 +0000 Subject: [PATCH 016/114] ci: self-test the `xphp check` gate end-to-end (+ PHAR smoke) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .github/workflows/ci-core.yml | 24 ++++++++++++++++ .github/workflows/release.yml | 7 +++++ Makefile | 9 ++++++ test/smoke/check.sh | 54 +++++++++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+) create mode 100755 test/smoke/check.sh diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index 5115e79..46bc492 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -66,6 +66,30 @@ jobs: - name: Run PHPStan run: make lint/phpstan + check: + # End-to-end self-test of the `xphp check` gate: runs the real bin/xphp + # binary against the check fixtures and asserts the 0/1/2 exit contract. + # The in-process CheckCommandTest can't observe the shipped binary's + # process exit code or its rendered stdout, so this covers that gap. + name: xphp check (self-test) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP 8.4 + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: dom, json, mbstring, tokenizer + coverage: none + tools: composer:v2 + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + - name: Run xphp check self-test + run: make test/check + infection: name: Mutation testing runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 03d8c72..c8b15af 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,6 +36,13 @@ jobs: # release fails BEFORE the asset is uploaded. run: php dist/xphp.phar list + - name: Smoke-test `check` on the PHAR + # Exercise the released artifact as a real gate: same 0/1/2 + # exit-contract assertions as CI, but against dist/xphp.phar + # instead of bin/xphp. Fails the release before upload if the + # packaged binary can't validate sources. + run: make test/check XPHP_BIN="php dist/xphp.phar" + - name: Compute SHA256 sum # `sha256sum` outputs ` `; running it from # the dist/ directory keeps the filename relative so users diff --git a/Makefile b/Makefile index 3288b02..aad524a 100644 --- a/Makefile +++ b/Makefile @@ -35,6 +35,15 @@ lint/phpstan: test/mutation: php -d memory_limit=-1 vendor/bin/infection --show-mutations=max --threads=max --min-covered-msi=95 +.PHONY: test/check +# End-to-end self-test of the `check` gate: runs the real bin/xphp binary +# against the check fixtures and asserts the 0/1/2 exit contract plus that the +# text/json/github renderers all emit. Reused by release.yml against the built +# PHAR (override XPHP_BIN="php dist/xphp.phar"). Complements the in-process +# CheckCommandTest, which can't observe the shipped binary's process exit code. +test/check: + sh test/smoke/check.sh + # Humbug Box is the standard tool for compiling a Composer-managed # PHP project into a single self-contained PHAR. Pinned to a known- # good release (Box 4.6.6 supports PHP 8.4) so a new Box version diff --git a/test/smoke/check.sh b/test/smoke/check.sh new file mode 100755 index 0000000..f2fe831 --- /dev/null +++ b/test/smoke/check.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env sh +# +# End-to-end self-test of the `xphp check` gate. +# +# Runs the REAL binary (not the in-process CommandTester) against the check +# fixtures and asserts the 0/1/2 exit contract plus that every renderer emits: +# 0 = clean 1 = at least one error 2 = operational failure +# +# Parameterized by the binary under test so the same assertions cover both the +# dev entrypoint and the released PHAR: +# XPHP_BIN="php bin/xphp" (default, CI self-test) +# XPHP_BIN="php dist/xphp.phar" (release.yml, post-build) +set -eu + +XPHP="${XPHP_BIN:-php bin/xphp}" +FIX="test/fixture/check" + +# check_exit +# Captures stdout+stderr into the global OUT; fails loudly on a code mismatch. +check_exit() { + set +e + OUT=$($XPHP check "$2" --format="$3" 2>&1) + code=$? + set -e + if [ "$code" != "$1" ]; then + echo "FAIL: '$XPHP check $2 --format=$3' expected exit $1, got $code" + echo "$OUT" + exit 1 + fi +} + +# Assert the captured OUT is well-formed JSON (php is always present; avoids a jq dep). +valid_json() { + printf '%s' "$OUT" | php -r 'json_decode(stream_get_contents(STDIN), false, 512, JSON_THROW_ON_ERROR);' \ + || { echo "FAIL: output was not valid JSON"; echo "$OUT"; exit 1; } +} + +# clean sources → exit 0, and all three renderers emit without error. +check_exit 0 "$FIX/clean/source" text +check_exit 0 "$FIX/clean/source" github +check_exit 0 "$FIX/clean/source" json; valid_json +echo "[ok] clean passes (exit 0) in text/json/github" + +# known-bad sources → exit 1, a GitHub annotation is emitted, json stays well-formed. +check_exit 1 "$FIX/multi_error/source" github +printf '%s' "$OUT" | grep -q '^::error file=' || { echo "FAIL: no ::error annotation"; echo "$OUT"; exit 1; } +check_exit 1 "$FIX/multi_error/source" json; valid_json +echo "[ok] multi_error fails (exit 1) with ::error annotation + valid json" + +# operational failure: a missing source dir is exit 2, distinct from a found-errors exit 1. +check_exit 2 "$FIX/does-not-exist" text +echo "[ok] missing source dir is operational failure (exit 2)" + +echo "check smoke: PASS ($XPHP)" From dfc79c0a81f5634cfde0d4dff9fd920f8ba0f94e Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 18 Jun 2026 12:39:50 +0000 Subject: [PATCH 017/114] feat(check): foundations for running PHPStan over compiled output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- composer.json | 3 +- composer.lock | 132 ++++++------ src/StaticAnalysis/CompiledWorkspace.php | 110 ++++++++++ src/StaticAnalysis/PhpStanConfigResolver.php | 44 ++++ src/StaticAnalysis/PhpStanLocator.php | 64 ++++++ test/StaticAnalysis/CompiledWorkspaceTest.php | 193 ++++++++++++++++++ .../PhpStanConfigResolverTest.php | 100 +++++++++ test/StaticAnalysis/PhpStanLocatorTest.php | 136 ++++++++++++ 8 files changed, 715 insertions(+), 67 deletions(-) create mode 100644 src/StaticAnalysis/CompiledWorkspace.php create mode 100644 src/StaticAnalysis/PhpStanConfigResolver.php create mode 100644 src/StaticAnalysis/PhpStanLocator.php create mode 100644 test/StaticAnalysis/CompiledWorkspaceTest.php create mode 100644 test/StaticAnalysis/PhpStanConfigResolverTest.php create mode 100644 test/StaticAnalysis/PhpStanLocatorTest.php diff --git a/composer.json b/composer.json index 255bce2..553e08a 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,8 @@ "require": { "php": "^8.4.0", "nikic/php-parser": "^5.7", - "symfony/console": "^8.0" + "symfony/console": "^8.0", + "symfony/process": "^8.0" }, "require-dev": { "phpunit/phpunit": "^13.0", diff --git a/composer.lock b/composer.lock index f57c263..53e38d9 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "cb46e25ce55ce78ec96df2693e53d69a", + "content-hash": "540a07ad52596c518e7921c95c6f9d60", "packages": [ { "name": "nikic/php-parser", @@ -613,6 +613,71 @@ ], "time": "2026-04-10T17:25:58+00:00" }, + { + "name": "symfony/process", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", + "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", + "shasum": "" + }, + "require": { + "php": ">=8.4.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, { "name": "symfony/service-contracts", "version": "v3.7.0", @@ -4076,71 +4141,6 @@ ], "time": "2026-04-26T13:10:57+00:00" }, - { - "name": "symfony/process", - "version": "v8.0.11", - "source": { - "type": "git", - "url": "https://github.com/symfony/process.git", - "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/26d89e459f037d2873300605d0a07e7a8ef84db0", - "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0", - "shasum": "" - }, - "require": { - "php": ">=8.4" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Process\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Executes commands in sub-processes", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/process/tree/v8.0.11" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-05-11T16:56:32+00:00" - }, { "name": "thecodingmachine/safe", "version": "v3.4.0", diff --git a/src/StaticAnalysis/CompiledWorkspace.php b/src/StaticAnalysis/CompiledWorkspace.php new file mode 100644 index 0000000..07c35a6 --- /dev/null +++ b/src/StaticAnalysis/CompiledWorkspace.php @@ -0,0 +1,110 @@ +compile($sources, $sourceDir, $distDir, $cacheDir); + + return new self( + $root, + $distDir, + $cacheDir . '/' . self::GENERATED_SUBDIR, + $result->registry, + ); + } + + /** Recursively delete the workspace. Safe to call when $root was never created. */ + public function cleanup(): void + { + self::removeRecursively($this->root); + } + + private static function removeRecursively(string $path): void + { + if (!is_dir($path)) { + return; + } + + $entries = scandir($path); + if ($entries === false) { + return; + } + + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $full = $path . '/' . $entry; + // Check is_link() BEFORE is_dir(): is_dir() follows a symlink to a + // directory, so recursing on it would delete the link TARGET's contents + // (outside the workspace). Always just unlink links. + if (is_link($full)) { + unlink($full); + } elseif (is_dir($full)) { + self::removeRecursively($full); + } else { + unlink($full); + } + } + + rmdir($path); + } +} diff --git a/src/StaticAnalysis/PhpStanConfigResolver.php b/src/StaticAnalysis/PhpStanConfigResolver.php new file mode 100644 index 0000000..7624da6 --- /dev/null +++ b/src/StaticAnalysis/PhpStanConfigResolver.php @@ -0,0 +1,44 @@ +workingDir . '/' . $name; + if (is_file($candidate)) { + return $candidate; + } + } + + return null; + } +} diff --git a/src/StaticAnalysis/PhpStanLocator.php b/src/StaticAnalysis/PhpStanLocator.php new file mode 100644 index 0000000..82ea082 --- /dev/null +++ b/src/StaticAnalysis/PhpStanLocator.php @@ -0,0 +1,64 @@ + $pathDirs directories from `$PATH`, already split */ + public function __construct( + private string $workingDir, + private array $pathDirs, + ) { + } + + public static function fromEnvironment(string $workingDir): self + { + // getenv returns string|false; an empty/blank PATH explodes to harmless + // empty-string dirs (no real directory is named ''), so the only case worth + // guarding is the false (unset) one. + $path = getenv('PATH'); + $dirs = is_string($path) ? explode(PATH_SEPARATOR, $path) : []; + + return new self($workingDir, $dirs); + } + + public function locate(?string $explicit): ?string + { + if ($explicit !== null && $explicit !== '') { + return self::usable($explicit) ? $explicit : null; + } + + $vendorBin = $this->workingDir . '/vendor/bin/phpstan'; + if (self::usable($vendorBin)) { + return $vendorBin; + } + + foreach ($this->pathDirs as $dir) { + $candidate = rtrim($dir, '/') . '/phpstan'; + if (self::usable($candidate)) { + return $candidate; + } + } + + return null; + } + + private static function usable(string $path): bool + { + return is_file($path); + } +} diff --git a/test/StaticAnalysis/CompiledWorkspaceTest.php b/test/StaticAnalysis/CompiledWorkspaceTest.php new file mode 100644 index 0000000..21441bc --- /dev/null +++ b/test/StaticAnalysis/CompiledWorkspaceTest.php @@ -0,0 +1,193 @@ +scratchRoot(); + $workspace = CompiledWorkspace::compile( + $this->compiler(), + $this->boxGenericSources(), + $this->boxGenericSourceDir(), + $root, + ); + + try { + self::assertSame($root, $workspace->root); + self::assertSame($root . '/dist', $workspace->distDir); + self::assertSame($root . '/cache/Generated', $workspace->generatedDir); + + // Rewritten user code landed in dist/. + self::assertFileExists($workspace->distDir . '/Containers/Box.php'); + // Specialized classes landed under cache/Generated/. + $generated = glob($workspace->generatedDir . '/App/BoxGeneric/Containers/Box/*.php') ?: []; + self::assertNotEmpty($generated, 'expected specialized Box classes under generatedDir'); + + // The live registry is retained for back-mapping (decl line lookup). + self::assertNotEmpty($workspace->registry->definitions()); + self::assertNotEmpty($workspace->registry->instantiations()); + } finally { + $workspace->cleanup(); + } + } + + public function testCleanupRemovesTheWorkspace(): void + { + $root = $this->scratchRoot(); + $workspace = CompiledWorkspace::compile( + $this->compiler(), + $this->boxGenericSources(), + $this->boxGenericSourceDir(), + $root, + ); + self::assertDirectoryExists($root); + + $workspace->cleanup(); + + self::assertDirectoryDoesNotExist($root); + } + + public function testInTempDirCreatesUniqueRootBeneathBase(): void + { + $base = $this->scratchRoot(); + mkdir($base, 0o755, true); + + // Pass a trailing slash so the regex (single separator) also pins rtrim(). + $a = CompiledWorkspace::inTempDir($this->compiler(), $this->boxGenericSources(), $this->boxGenericSourceDir(), $base . '/'); + $b = CompiledWorkspace::inTempDir($this->compiler(), $this->boxGenericSources(), $this->boxGenericSourceDir(), $base . '/'); + + try { + // Exactly one separator (rtrim normalises the base) then the tag and a + // 16-hex-char suffix (bin2hex of random_bytes(8)). + self::assertMatchesRegularExpression( + '#^' . preg_quote($base, '#') . '/xphp-check-[0-9a-f]{16}$#', + $a->root, + ); + self::assertNotSame($a->root, $b->root); + self::assertDirectoryExists($a->root); + self::assertDirectoryExists($b->root); + } finally { + $a->cleanup(); + $b->cleanup(); + $this->rrmdir($base); + } + } + + public function testCleanupIsIdempotentWhenRootIsAlreadyGone(): void + { + $root = $this->scratchRoot(); + $workspace = CompiledWorkspace::compile( + $this->compiler(), + new FilepathArray(), + $this->boxGenericSourceDir(), + $root, + ); + + $workspace->cleanup(); + self::assertDirectoryDoesNotExist($root); + + // A second cleanup hits the `!is_dir` short-circuit and must not throw. + $workspace->cleanup(); + $this->addToAssertionCount(1); + } + + public function testCleanupRemovesNestedTreeWithoutFollowingSymlinks(): void + { + // A hand-built workspace: nested files + a symlink pointing OUT of the + // workspace. cleanup() must delete the tree and the link, but never touch + // the link's target contents. + $root = $this->scratchRoot(); + mkdir($root . '/a/b', 0o755, true); + file_put_contents($root . '/a/b/file.php', 'scratchRoot(); + mkdir($external, 0o755, true); + file_put_contents($external . '/keep.txt', 'precious'); + symlink($external, $root . '/a/link-to-external'); + + $workspace = CompiledWorkspace::compile( + $this->compiler(), + new FilepathArray(), + $this->boxGenericSourceDir(), + $root, + ); + + try { + $workspace->cleanup(); + + self::assertDirectoryDoesNotExist($root); + // The symlink target and its contents must survive. + self::assertFileExists($external . '/keep.txt'); + self::assertSame('precious', file_get_contents($external . '/keep.txt')); + } finally { + $this->rrmdir($external); + } + } + + private function compiler(): Compiler + { + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + + return new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + } + + private function boxGenericSourceDir(): string + { + return realpath(__DIR__ . '/../fixture/compile/box_generic/source') + ?: throw new RuntimeException('box_generic fixture missing'); + } + + private function boxGenericSources(): FilepathArray + { + return (new NativeFileFinder()) + ->find($this->boxGenericSourceDir()) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + } + + private function scratchRoot(): string + { + return sys_get_temp_dir() . '/xphp-workspace-' . uniqid('', true); + } + + private function rrmdir(string $path): void + { + if (!is_dir($path)) { + return; + } + $entries = scandir($path) ?: []; + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $full = $path . '/' . $entry; + is_dir($full) ? $this->rrmdir($full) : unlink($full); + } + rmdir($path); + } +} diff --git a/test/StaticAnalysis/PhpStanConfigResolverTest.php b/test/StaticAnalysis/PhpStanConfigResolverTest.php new file mode 100644 index 0000000..350c238 --- /dev/null +++ b/test/StaticAnalysis/PhpStanConfigResolverTest.php @@ -0,0 +1,100 @@ +scratch = sys_get_temp_dir() . '/xphp-config-' . uniqid('', true); + if (!mkdir($this->scratch, 0o755, true) && !is_dir($this->scratch)) { + throw new RuntimeException("could not create scratch dir: {$this->scratch}"); + } + } + + protected function tearDown(): void + { + $entries = scandir($this->scratch) ?: []; + foreach ($entries as $entry) { + if ($entry !== '.' && $entry !== '..') { + unlink($this->scratch . '/' . $entry); + } + } + rmdir($this->scratch); + } + + public function testExplicitConfigWins(): void + { + $explicit = $this->scratch . '/custom.neon'; + file_put_contents($explicit, "parameters:\n"); + // An auto-detect candidate also present must be ignored in favour of the explicit one. + file_put_contents($this->scratch . '/phpstan.neon', "parameters:\n"); + + $resolver = new PhpStanConfigResolver($this->scratch); + + self::assertSame($explicit, $resolver->resolve($explicit)); + } + + public function testExplicitConfigThatDoesNotExistResolvesToNull(): void + { + $resolver = new PhpStanConfigResolver($this->scratch); + + self::assertNull($resolver->resolve($this->scratch . '/missing.neon')); + } + + public function testAutoDetectsPhpstanNeon(): void + { + $config = $this->scratch . '/phpstan.neon'; + file_put_contents($config, "parameters:\n"); + + $resolver = new PhpStanConfigResolver($this->scratch); + + self::assertSame($config, $resolver->resolve(null)); + } + + public function testAutoDetectPrefersNeonOverDistVariants(): void + { + $neon = $this->scratch . '/phpstan.neon'; + file_put_contents($neon, "parameters:\n"); + file_put_contents($this->scratch . '/phpstan.neon.dist', "parameters:\n"); + file_put_contents($this->scratch . '/phpstan.dist.neon', "parameters:\n"); + + $resolver = new PhpStanConfigResolver($this->scratch); + + self::assertSame($neon, $resolver->resolve(null)); + } + + public function testAutoDetectsNeonDistWhenPlainNeonAbsent(): void + { + $dist = $this->scratch . '/phpstan.neon.dist'; + file_put_contents($dist, "parameters:\n"); + + $resolver = new PhpStanConfigResolver($this->scratch); + + self::assertSame($dist, $resolver->resolve(null)); + } + + public function testReturnsNullWhenNoConfigPresent(): void + { + $resolver = new PhpStanConfigResolver($this->scratch); + + self::assertNull($resolver->resolve(null)); + } + + public function testEmptyExplicitStringFallsThroughToAutoDetect(): void + { + $config = $this->scratch . '/phpstan.neon'; + file_put_contents($config, "parameters:\n"); + + $resolver = new PhpStanConfigResolver($this->scratch); + + self::assertSame($config, $resolver->resolve('')); + } +} diff --git a/test/StaticAnalysis/PhpStanLocatorTest.php b/test/StaticAnalysis/PhpStanLocatorTest.php new file mode 100644 index 0000000..e248e90 --- /dev/null +++ b/test/StaticAnalysis/PhpStanLocatorTest.php @@ -0,0 +1,136 @@ +scratch = sys_get_temp_dir() . '/xphp-locator-' . uniqid('', true); + if (!mkdir($this->scratch, 0o755, true) && !is_dir($this->scratch)) { + throw new RuntimeException("could not create scratch dir: {$this->scratch}"); + } + } + + protected function tearDown(): void + { + $this->rrmdir($this->scratch); + } + + public function testExplicitPathWins(): void + { + $bin = $this->touch($this->scratch . '/custom-phpstan'); + $locator = new PhpStanLocator($this->scratch, []); + + self::assertSame($bin, $locator->locate($bin)); + } + + public function testExplicitPathThatDoesNotExistResolvesToNull(): void + { + $locator = new PhpStanLocator($this->scratch, []); + + self::assertNull($locator->locate($this->scratch . '/nope')); + } + + public function testFallsBackToConsumerVendorBin(): void + { + $vendorBin = $this->touch($this->scratch . '/vendor/bin/phpstan'); + $locator = new PhpStanLocator($this->scratch, []); + + self::assertSame($vendorBin, $locator->locate(null)); + } + + public function testFallsBackToPath(): void + { + $pathDir = $this->scratch . '/opt/bin'; + $onPath = $this->touch($pathDir . '/phpstan'); + // No vendor/bin/phpstan here, so PATH is the only resolution. + $locator = new PhpStanLocator($this->scratch, [$pathDir]); + + self::assertSame($onPath, $locator->locate(null)); + } + + public function testVendorBinTakesPrecedenceOverPath(): void + { + $vendorBin = $this->touch($this->scratch . '/vendor/bin/phpstan'); + $pathDir = $this->scratch . '/opt/bin'; + $this->touch($pathDir . '/phpstan'); + $locator = new PhpStanLocator($this->scratch, [$pathDir]); + + self::assertSame($vendorBin, $locator->locate(null)); + } + + public function testPathDirWithTrailingSlashStillResolves(): void + { + $pathDir = $this->scratch . '/opt/bin'; + $this->touch($pathDir . '/phpstan'); + // Trailing slash must be normalised (rtrim) so the candidate isn't `…/bin//phpstan`. + $locator = new PhpStanLocator($this->scratch, [$pathDir . '/']); + + self::assertSame($pathDir . '/phpstan', $locator->locate(null)); + } + + public function testReturnsNullWhenNothingResolves(): void + { + $locator = new PhpStanLocator($this->scratch, [$this->scratch . '/empty']); + + self::assertNull($locator->locate(null)); + } + + public function testEmptyExplicitStringIsTreatedAsAbsent(): void + { + $vendorBin = $this->touch($this->scratch . '/vendor/bin/phpstan'); + $locator = new PhpStanLocator($this->scratch, []); + + // '' must NOT short-circuit to "explicit"; it falls through to vendor/bin. + self::assertSame($vendorBin, $locator->locate('')); + } + + public function testFromEnvironmentSplitsPath(): void + { + $pathDir = $this->scratch . '/env/bin'; + $onPath = $this->touch($pathDir . '/phpstan'); + $original = getenv('PATH'); + putenv('PATH=' . $pathDir); + try { + $locator = PhpStanLocator::fromEnvironment($this->scratch); + self::assertSame($onPath, $locator->locate(null)); + } finally { + putenv($original === false ? 'PATH' : 'PATH=' . $original); + } + } + + private function touch(string $path): string + { + $dir = dirname($path); + if (!is_dir($dir) && !mkdir($dir, 0o755, true) && !is_dir($dir)) { + throw new RuntimeException("could not create dir: {$dir}"); + } + file_put_contents($path, "#!/usr/bin/env php\n"); + + return $path; + } + + private function rrmdir(string $path): void + { + if (!is_dir($path)) { + return; + } + $entries = scandir($path) ?: []; + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $full = $path . '/' . $entry; + is_dir($full) ? $this->rrmdir($full) : unlink($full); + } + rmdir($path); + } +} From 8e2784180ed6f0fe1f60fb6ed44a6959f0773fb3 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 18 Jun 2026 13:10:25 +0000 Subject: [PATCH 018/114] feat(check): run PHPStan over representative specializations and parse findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 analyse ` 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) --- src/StaticAnalysis/CompiledWorkspace.php | 16 +- src/StaticAnalysis/PhpStanFinding.php | 22 ++ src/StaticAnalysis/PhpStanOutputParser.php | 78 +++++++ src/StaticAnalysis/PhpStanResult.php | 33 +++ src/StaticAnalysis/PhpStanRunner.php | 116 ++++++++++ src/StaticAnalysis/Representative.php | 29 +++ src/StaticAnalysis/RepresentativeSelector.php | 88 ++++++++ test/StaticAnalysis/CompiledWorkspaceTest.php | 26 ++- .../PhpStanOutputParserTest.php | 205 ++++++++++++++++++ .../PhpStanRunnerConfigTest.php | 76 +++++++ test/StaticAnalysis/PhpStanRunnerTest.php | 182 ++++++++++++++++ .../RepresentativeSelectorTest.php | 143 ++++++++++++ .../check/body_type_error/source/Box.xphp | 26 +++ .../check/body_type_error/source/Use.xphp | 9 + 14 files changed, 1045 insertions(+), 4 deletions(-) create mode 100644 src/StaticAnalysis/PhpStanFinding.php create mode 100644 src/StaticAnalysis/PhpStanOutputParser.php create mode 100644 src/StaticAnalysis/PhpStanResult.php create mode 100644 src/StaticAnalysis/PhpStanRunner.php create mode 100644 src/StaticAnalysis/Representative.php create mode 100644 src/StaticAnalysis/RepresentativeSelector.php create mode 100644 test/StaticAnalysis/PhpStanOutputParserTest.php create mode 100644 test/StaticAnalysis/PhpStanRunnerConfigTest.php create mode 100644 test/StaticAnalysis/PhpStanRunnerTest.php create mode 100644 test/StaticAnalysis/RepresentativeSelectorTest.php create mode 100644 test/fixture/check/body_type_error/source/Box.xphp create mode 100644 test/fixture/check/body_type_error/source/Use.xphp diff --git a/src/StaticAnalysis/CompiledWorkspace.php b/src/StaticAnalysis/CompiledWorkspace.php index 07c35a6..9c27bb7 100644 --- a/src/StaticAnalysis/CompiledWorkspace.php +++ b/src/StaticAnalysis/CompiledWorkspace.php @@ -62,14 +62,26 @@ public static function compile( // $root needs no pre-creation; an empty source set simply writes nothing. $result = $compiler->compile($sources, $sourceDir, $distDir, $cacheDir); + // Canonicalize the analysable dirs: PHPStan reports findings under + // realpath()'d paths (symlinks resolved, e.g. macOS /var -> /private/var), + // so the representative file paths built from these must be canonical too + // or the finding->representative join silently misses (a false clean pass). return new self( $root, - $distDir, - $cacheDir . '/' . self::GENERATED_SUBDIR, + self::canonical($distDir), + self::canonical($cacheDir . '/' . self::GENERATED_SUBDIR), $result->registry, ); } + /** realpath() the dir if it exists; otherwise keep the constructed path (no files to match). */ + private static function canonical(string $dir): string + { + $real = realpath($dir); + + return $real !== false ? $real : $dir; + } + /** Recursively delete the workspace. Safe to call when $root was never created. */ public function cleanup(): void { diff --git a/src/StaticAnalysis/PhpStanFinding.php b/src/StaticAnalysis/PhpStanFinding.php new file mode 100644 index 0000000..5a88b99 --- /dev/null +++ b/src/StaticAnalysis/PhpStanFinding.php @@ -0,0 +1,22 @@ + $data) { + if (!is_string($file) || !is_array($data) || !isset($data['messages']) || !is_array($data['messages'])) { + continue; + } + foreach ($data['messages'] as $message) { + if (!is_array($message) || !isset($message['message']) || !is_string($message['message'])) { + continue; + } + // Generated-class findings map to the template DECLARATION line, not the + // finding's own line, so a missing/odd line is harmless — default to 0. + $line = isset($message['line']) && is_int($message['line']) ? $message['line'] : 0; + $identifier = isset($message['identifier']) && is_string($message['identifier']) + ? $message['identifier'] + : null; + $findings[] = new PhpStanFinding($file, $line, $message['message'], $identifier); + } + } + + // Valid JSON but only top-level (file-less) errors means PHPStan itself had a + // problem (e.g. an unmatched ignore pattern) — not a clean pass. + $topLevel = $decoded['errors'] ?? []; + if ($findings === [] && is_array($topLevel) && $topLevel !== []) { + $joined = implode("\n", array_map(static fn (mixed $e): string => is_string($e) ? $e : '', $topLevel)); + + return PhpStanResult::failed(trim($joined) !== '' ? $joined : 'PHPStan reported a general error'); + } + + return PhpStanResult::ok($findings); + } + + private static function failureMessage(string $stdout, string $stderr): string + { + $stderr = trim($stderr); + if ($stderr !== '') { + return $stderr; + } + $stdout = trim($stdout); + if ($stdout !== '') { + return $stdout; + } + + return 'PHPStan produced no analysable output'; + } +} diff --git a/src/StaticAnalysis/PhpStanResult.php b/src/StaticAnalysis/PhpStanResult.php new file mode 100644 index 0000000..7ded66b --- /dev/null +++ b/src/StaticAnalysis/PhpStanResult.php @@ -0,0 +1,33 @@ + $findings */ + private function __construct( + public bool $ranOk, + public array $findings, + public ?string $errorOutput, + ) { + } + + /** @param list $findings */ + public static function ok(array $findings): self + { + return new self(true, $findings, null); + } + + public static function failed(string $errorOutput): self + { + return new self(false, [], $errorOutput); + } +} diff --git a/src/StaticAnalysis/PhpStanRunner.php b/src/StaticAnalysis/PhpStanRunner.php new file mode 100644 index 0000000..999b515 --- /dev/null +++ b/src/StaticAnalysis/PhpStanRunner.php @@ -0,0 +1,116 @@ +` is invoked (rather than the bin directly) so a resolved + * path needs no execute bit and a `.phar`/proxy works the same way. + */ +final readonly class PhpStanRunner +{ + private const int TIMEOUT_SECONDS = 300; + private const int DEFAULT_LEVEL = 5; + + public function __construct(private string $phpstanBin) + { + } + + /** + * @param list $analysePaths absolute representative file paths to analyse + * @param list $scanDirectories absolute dirs scanned for symbol resolution (dist, Generated, vendor) + * @param ?string $consumerConfig absolute path to the consumer's neon, or null + * @param string $ephemeralConfigPath where to write the generated config (inside the workspace) + */ + public function run( + array $analysePaths, + array $scanDirectories, + ?string $consumerConfig, + string $ephemeralConfigPath, + ): PhpStanResult { + file_put_contents($ephemeralConfigPath, self::buildConfig($scanDirectories, $consumerConfig)); + + $process = new Process(self::buildCommand($this->phpstanBin, $ephemeralConfigPath, $analysePaths)); + // @infection-ignore-all MethodCallRemoval -- the timeout is a runaway guard; + // dropping it leaves Symfony's 60s default, which produces an identical result + // for every analysable input. Not observable without a >60s-hanging fixture. + $process->setTimeout(self::TIMEOUT_SECONDS); + $process->run(); + + return PhpStanOutputParser::parse($process->getOutput(), $process->getErrorOutput()); + } + + /** + * The argv used to invoke PHPStan. `php ` (not the bin directly) so a + * resolved path needs no execute bit and a `.phar`/proxy works the same way. + * Exposed (static, pure) so the exact command is unit-testable. + * + * @param list $analysePaths + * @return list + */ + public static function buildCommand(string $phpstanBin, string $configPath, array $analysePaths): array + { + return [ + PHP_BINARY, + $phpstanBin, + 'analyse', + '--no-progress', + '--error-format=json', + // Lift the default 128M ceiling: scanning the consumer's vendor dir for + // symbol resolution routinely exceeds it, which would fatal (no JSON) and + // degrade the gate to a Warning. -1 defers to the OS rather than imposing + // an arbitrary cap that's wrong for large projects. + '--memory-limit=-1', + '--configuration=' . $configPath, + ...$analysePaths, + ]; + } + + /** + * The ephemeral NEON the run writes. Exposed (static, pure) so its exact + * shape is unit-testable without invoking PHPStan. + * + * @param list $scanDirectories + */ + public static function buildConfig(array $scanDirectories, ?string $consumerConfig): string + { + $lines = []; + + if ($consumerConfig !== null) { + $lines[] = 'includes:'; + $lines[] = ' - ' . self::neonString($consumerConfig); + } + + $lines[] = 'parameters:'; + if ($consumerConfig === null) { + // No consumer config to inherit a level from — pick a modest default + // so PHPStan still does something useful. + $lines[] = ' level: ' . self::DEFAULT_LEVEL; + } + $lines[] = ' scanDirectories:'; + foreach ($scanDirectories as $dir) { + $lines[] = ' - ' . self::neonString($dir); + } + + return implode("\n", $lines) . "\n"; + } + + private static function neonString(string $value): string + { + return '"' . addcslashes($value, '"\\') . '"'; + } +} diff --git a/src/StaticAnalysis/Representative.php b/src/StaticAnalysis/Representative.php new file mode 100644 index 0000000..42753a8 --- /dev/null +++ b/src/StaticAnalysis/Representative.php @@ -0,0 +1,29 @@ + + */ + public static function select(Registry $registry, string $generatedDir): array + { + /** @var array> $byTemplate */ + $byTemplate = []; + foreach ($registry->instantiations() as $instantiation) { + $byTemplate[$instantiation->templateFqn][] = $instantiation; + } + + $representatives = []; + foreach ($byTemplate as $templateFqn => $instantiations) { + // Invariant: the parser emits template FQNs without a leading backslash, so + // the instantiation's templateFqn matches the key recordDefinition stored + // under — this lookup hits for every defined template. + $definition = $registry->definition($templateFqn); + if ($definition === null) { + // Instantiated-but-never-defined: there's no declaration to map a + // finding back to. The generic checks already report this; skip it + // here rather than emit a finding with no source location. + continue; + } + + usort( + $instantiations, + static fn (GenericInstantiation $a, GenericInstantiation $b): int + => strcmp($a->generatedFqn, $b->generatedFqn), + ); + $chosen = $instantiations[0]; + + $representatives[] = new Representative( + $chosen->generatedFqn, + self::fqnToPath($chosen->generatedFqn, $generatedDir), + $templateFqn, + self::label($chosen), + $definition->sourceFile, + $definition->templateAst->getStartLine(), + ); + } + + usort( + $representatives, + static fn (Representative $a, Representative $b): int => strcmp($a->generatedFqn, $b->generatedFqn), + ); + + return $representatives; + } + + private static function fqnToPath(string $generatedFqn, string $generatedDir): string + { + $prefix = Registry::GENERATED_NAMESPACE_PREFIX . '\\'; + $relative = str_starts_with($generatedFqn, $prefix) + ? substr($generatedFqn, strlen($prefix)) + : $generatedFqn; + + return rtrim($generatedDir, '/') . '/' . str_replace('\\', '/', $relative) . '.php'; + } + + private static function label(GenericInstantiation $instantiation): string + { + $args = array_map( + static fn (TypeRef $type): string => $type->toDisplayString(), + $instantiation->concreteTypes, + ); + + return $instantiation->templateFqn . '<' . implode(', ', $args) . '>'; + } +} diff --git a/test/StaticAnalysis/CompiledWorkspaceTest.php b/test/StaticAnalysis/CompiledWorkspaceTest.php index 21441bc..cb0652f 100644 --- a/test/StaticAnalysis/CompiledWorkspaceTest.php +++ b/test/StaticAnalysis/CompiledWorkspaceTest.php @@ -31,8 +31,9 @@ public function testCompileEmitsDistAndGeneratedAndKeepsRegistry(): void try { self::assertSame($root, $workspace->root); - self::assertSame($root . '/dist', $workspace->distDir); - self::assertSame($root . '/cache/Generated', $workspace->generatedDir); + // distDir/generatedDir are canonicalized (realpath) for PHPStan path matching. + self::assertSame(realpath($root . '/dist'), $workspace->distDir); + self::assertSame(realpath($root . '/cache/Generated'), $workspace->generatedDir); // Rewritten user code landed in dist/. self::assertFileExists($workspace->distDir . '/Containers/Box.php'); @@ -90,6 +91,27 @@ public function testInTempDirCreatesUniqueRootBeneathBase(): void } } + public function testGeneratedDirFallsBackToConstructedPathWhenNotCreated(): void + { + // Empty sources emit no specialized classes, so cache/Generated is never + // created and realpath() returns false — canonical() must fall back to the + // constructed path (harmless: there are no representatives to match anyway). + $root = $this->scratchRoot(); + $workspace = CompiledWorkspace::compile( + $this->compiler(), + new FilepathArray(), + $this->boxGenericSourceDir(), + $root, + ); + + try { + self::assertDirectoryDoesNotExist($root . '/cache/Generated'); + self::assertSame($root . '/cache/Generated', $workspace->generatedDir); + } finally { + $workspace->cleanup(); + } + } + public function testCleanupIsIdempotentWhenRootIsAlreadyGone(): void { $root = $this->scratchRoot(); diff --git a/test/StaticAnalysis/PhpStanOutputParserTest.php b/test/StaticAnalysis/PhpStanOutputParserTest.php new file mode 100644 index 0000000..75f77e1 --- /dev/null +++ b/test/StaticAnalysis/PhpStanOutputParserTest.php @@ -0,0 +1,205 @@ + ['errors' => 0, 'file_errors' => 1], + 'files' => [ + '/tmp/gen/Box.php' => [ + 'errors' => 1, + 'messages' => [ + ['message' => 'should return int but returns string', 'line' => 15, 'identifier' => 'return.type'], + ], + ], + ], + 'errors' => [], + ], JSON_THROW_ON_ERROR); + + $result = PhpStanOutputParser::parse($json, ''); + + self::assertTrue($result->ranOk); + self::assertCount(1, $result->findings); + $finding = $result->findings[0]; + self::assertSame('/tmp/gen/Box.php', $finding->file); + self::assertSame(15, $finding->line); + self::assertSame('should return int but returns string', $finding->message); + self::assertSame('return.type', $finding->identifier); + } + + public function testIdentifierIsNullWhenAbsentAndLineDefaultsToZero(): void + { + $json = json_encode([ + 'files' => [ + '/tmp/gen/Box.php' => [ + 'messages' => [ + ['message' => 'file-level problem'], // no line, no identifier + ], + ], + ], + ], JSON_THROW_ON_ERROR); + + $result = PhpStanOutputParser::parse($json, ''); + + self::assertTrue($result->ranOk); + self::assertCount(1, $result->findings); + self::assertSame(0, $result->findings[0]->line); + self::assertNull($result->findings[0]->identifier); + } + + public function testEmptyFilesMapIsACleanRun(): void + { + $result = PhpStanOutputParser::parse('{"files":{},"errors":[]}', ''); + + self::assertTrue($result->ranOk); + self::assertSame([], $result->findings); + } + + public function testNonJsonStdoutIsAFailedRunWithStderrMessage(): void + { + $result = PhpStanOutputParser::parse('PHP Fatal error: boom', 'Configuration file not found'); + + self::assertFalse($result->ranOk); + self::assertSame([], $result->findings); + self::assertSame('Configuration file not found', $result->errorOutput); + } + + public function testFailureMessageFallsBackToStdoutWhenStderrEmpty(): void + { + $result = PhpStanOutputParser::parse('not json at all', ' '); + + self::assertFalse($result->ranOk); + self::assertSame('not json at all', $result->errorOutput); + } + + public function testFailureMessageWhenBothStreamsEmpty(): void + { + $result = PhpStanOutputParser::parse('', ''); + + self::assertFalse($result->ranOk); + self::assertSame('PHPStan produced no analysable output', $result->errorOutput); + } + + public function testValidJsonWithoutFilesKeyIsAFailedRun(): void + { + $result = PhpStanOutputParser::parse('{"totals":{"errors":0}}', 'some stderr'); + + self::assertFalse($result->ranOk); + self::assertSame('some stderr', $result->errorOutput); + } + + public function testFilesKeySetButNotAnArrayIsAFailedRun(): void + { + // 'files' present but not a map (distinguishes the middle clause of the + // is_array/isset/is_array guard from the others). + $result = PhpStanOutputParser::parse('{"files":"oops"}', 'stderr text'); + + self::assertFalse($result->ranOk); + self::assertSame('stderr text', $result->errorOutput); + } + + public function testFailureMessageTrimsStdoutWhitespace(): void + { + $result = PhpStanOutputParser::parse(' padded ', ''); + + self::assertFalse($result->ranOk); + self::assertSame('padded', $result->errorOutput); + } + + public function testTopLevelErrorsCoerceNonStringsToEmpty(): void + { + $json = json_encode([ + 'files' => [], + 'errors' => ['real error', 123], // the int must be coerced to '' by the map + ], JSON_THROW_ON_ERROR); + + $result = PhpStanOutputParser::parse($json, ''); + + self::assertFalse($result->ranOk); + self::assertSame("real error\n", $result->errorOutput); + } + + public function testWhitespaceOnlyTopLevelErrorFallsBackToGenericMessage(): void + { + $json = json_encode(['files' => [], 'errors' => [' ']], JSON_THROW_ON_ERROR); + + $result = PhpStanOutputParser::parse($json, ''); + + self::assertFalse($result->ranOk); + self::assertSame('PHPStan reported a general error', $result->errorOutput); + } + + public function testScalarJsonIsAFailedRun(): void + { + // json_decode('123') is a valid int, not an array — must be a failed run. + $result = PhpStanOutputParser::parse('123', 'stderr here'); + + self::assertFalse($result->ranOk); + self::assertSame('stderr here', $result->errorOutput); + } + + public function testTopLevelErrorsWithNoFileFindingsIsAFailedRun(): void + { + $json = json_encode([ + 'files' => [], + 'errors' => ['Ignored error pattern was not matched', 'Another general error'], + ], JSON_THROW_ON_ERROR); + + $result = PhpStanOutputParser::parse($json, ''); + + self::assertFalse($result->ranOk); + self::assertStringContainsString('Ignored error pattern was not matched', $result->errorOutput ?? ''); + self::assertStringContainsString('Another general error', $result->errorOutput ?? ''); + } + + public function testFileFindingsWinOverTopLevelErrors(): void + { + // When there ARE file findings, top-level errors don't turn it into a failure. + $json = json_encode([ + 'files' => [ + '/tmp/gen/Box.php' => ['messages' => [['message' => 'real bug', 'line' => 3]]], + ], + 'errors' => ['some general note'], + ], JSON_THROW_ON_ERROR); + + $result = PhpStanOutputParser::parse($json, ''); + + self::assertTrue($result->ranOk); + self::assertCount(1, $result->findings); + } + + public function testMalformedEntriesAreSkippedNotFatal(): void + { + $json = json_encode([ + 'files' => [ + '/tmp/ok.php' => ['messages' => [['message' => 'kept', 'line' => 1]]], + '/tmp/no-messages.php' => ['errors' => 2], // missing 'messages' + '/tmp/bad-messages.php' => ['messages' => 'not-an-array'], + '/tmp/scalar-data.php' => 'not-an-array', // data not an array → skipped + // A numeric file key decodes to an int key, exercising the is_string($file) guard. + '7' => ['messages' => [['message' => 'numeric-key', 'line' => 1]]], + '/tmp/bad-entry.php' => [ + 'messages' => [ + 'not-an-array', // skipped + ['line' => 9], // no 'message' → skipped + ['message' => 42], // non-string message → skipped + ['message' => 'also kept', 'line' => 2], + ], + ], + ], + ], JSON_THROW_ON_ERROR); + + $result = PhpStanOutputParser::parse($json, ''); + + self::assertTrue($result->ranOk); + $messages = array_map(static fn (PhpStanFinding $f): string => $f->message, $result->findings); + self::assertSame(['kept', 'also kept'], $messages); + } +} diff --git a/test/StaticAnalysis/PhpStanRunnerConfigTest.php b/test/StaticAnalysis/PhpStanRunnerConfigTest.php new file mode 100644 index 0000000..7fb70a7 --- /dev/null +++ b/test/StaticAnalysis/PhpStanRunnerConfigTest.php @@ -0,0 +1,76 @@ +phpstanBin = $bin; + } + + public function testReportsBodyTypeErrorInTheRepresentative(): void + { + $this->withCompiled('check/body_type_error', function (CompiledWorkspace $w): void { + $reps = RepresentativeSelector::select($w->registry, $w->generatedDir); + $result = $this->analyze($w, $reps); + + self::assertTrue($result->ranOk, 'phpstan should run cleanly; got: ' . ($result->errorOutput ?? '')); + self::assertCount(1, $result->findings); + + $finding = $result->findings[0]; + self::assertSame($reps[0]->filePath, $finding->file); + self::assertStringContainsString('should return int but returns string', $finding->message); + }); + } + + public function testCleanSourcesProduceNoFindings(): void + { + $this->withCompiled('compile/box_generic', function (CompiledWorkspace $w): void { + $reps = RepresentativeSelector::select($w->registry, $w->generatedDir); + $result = $this->analyze($w, $reps); + + self::assertTrue($result->ranOk, 'phpstan should run cleanly; got: ' . ($result->errorOutput ?? '')); + self::assertSame([], $result->findings); + }); + } + + public function testBrokenConsumerConfigIsReportedAsRunFailureNotACleanPass(): void + { + $this->withCompiled('check/body_type_error', function (CompiledWorkspace $w): void { + $reps = RepresentativeSelector::select($w->registry, $w->generatedDir); + + // A consumer config that includes a non-existent file makes PHPStan fatal + // with no JSON on stdout — must surface as a failed run, never 0 findings. + $badConfig = $w->root . '/bad-consumer.neon'; + file_put_contents($badConfig, "includes:\n - /no/such/file/phpstan.neon\n"); + + $result = (new PhpStanRunner($this->phpstanBin))->run( + array_map(static fn (Representative $r): string => $r->filePath, $reps), + [$w->distDir, $w->generatedDir], + $badConfig, + $w->root . '/ephemeral.neon', + ); + + self::assertFalse($result->ranOk); + self::assertSame([], $result->findings); + self::assertNotNull($result->errorOutput); + self::assertNotSame('', $result->errorOutput); + }); + } + + public function testFindingPathMatchesRepresentativeEvenThroughASymlinkedRoot(): void + { + // Compile into a workspace whose root is reached via a symlink. PHPStan + // reports realpath()'d paths; the representative file path must resolve to + // the same canonical form, or the finding->representative join silently + // misses and the gate falsely passes. (Regression guard for that join.) + $realBase = sys_get_temp_dir() . '/xphp-real-' . uniqid('', true); + $linkBase = sys_get_temp_dir() . '/xphp-link-' . uniqid('', true); + mkdir($realBase, 0o755, true); + symlink($realBase, $linkBase); + + $sourceDir = realpath(__DIR__ . '/../fixture/check/body_type_error/source') + ?: throw new RuntimeException('fixture missing'); + $sources = (new NativeFileFinder()) + ->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + + $workspace = CompiledWorkspace::compile($this->compiler(), $sources, $sourceDir, $linkBase . '/ws'); + try { + $reps = RepresentativeSelector::select($workspace->registry, $workspace->generatedDir); + $result = $this->analyze($workspace, $reps); + + self::assertTrue($result->ranOk, 'phpstan should run; got: ' . ($result->errorOutput ?? '')); + self::assertCount(1, $result->findings); + self::assertSame($reps[0]->filePath, $result->findings[0]->file); + } finally { + $workspace->cleanup(); + unlink($linkBase); + @rmdir($realBase); + } + } + + public function testResolvedButInvalidBinaryIsAFailedRunNotAnException(): void + { + $this->withCompiled('check/body_type_error', function (CompiledWorkspace $w): void { + $reps = RepresentativeSelector::select($w->registry, $w->generatedDir); + $bogusBin = $w->root . '/not-really-phpstan'; + file_put_contents($bogusBin, "run( + array_map(static fn (Representative $r): string => $r->filePath, $reps), + [$w->distDir, $w->generatedDir], + null, + $w->root . '/ephemeral.neon', + ); + + self::assertFalse($result->ranOk); + self::assertSame([], $result->findings); + }); + } + + private function analyze(CompiledWorkspace $w, array $reps): PhpStanResult + { + $vendorDir = realpath(__DIR__ . '/../../vendor') ?: throw new RuntimeException('vendor dir missing'); + + return (new PhpStanRunner($this->phpstanBin))->run( + array_map(static fn (Representative $r): string => $r->filePath, $reps), + [$w->distDir, $w->generatedDir, $vendorDir], + null, // no consumer config → default level + $w->root . '/ephemeral.neon', + ); + } + + /** @param callable(CompiledWorkspace): void $assertions */ + private function withCompiled(string $fixture, callable $assertions): void + { + $sourceDir = realpath(__DIR__ . '/../fixture/' . $fixture . '/source') + ?: throw new RuntimeException("fixture missing: {$fixture}"); + $sources = (new NativeFileFinder()) + ->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + + $workspace = CompiledWorkspace::compile( + $this->compiler(), + $sources, + $sourceDir, + sys_get_temp_dir() . '/xphp-runner-' . uniqid('', true), + ); + try { + $assertions($workspace); + } finally { + $workspace->cleanup(); + } + } + + private function compiler(): Compiler + { + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + + return new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + } +} diff --git a/test/StaticAnalysis/RepresentativeSelectorTest.php b/test/StaticAnalysis/RepresentativeSelectorTest.php new file mode 100644 index 0000000..5014523 --- /dev/null +++ b/test/StaticAnalysis/RepresentativeSelectorTest.php @@ -0,0 +1,143 @@ +withCompiled('compile/multi_type', function (CompiledWorkspace $w): void { + $reps = RepresentativeSelector::select($w->registry, $w->generatedDir); + + self::assertCount(2, $reps); + $templates = array_map(static fn (Representative $r): string => $r->templateFqn, $reps); + self::assertContains('App\\MultiType\\Containers\\Pair', $templates); + self::assertContains('App\\MultiType\\Containers\\Map', $templates); + + foreach ($reps as $rep) { + self::assertFileExists($rep->filePath, "representative file should exist: {$rep->filePath}"); + self::assertStringStartsWith($w->generatedDir, $rep->filePath); + self::assertGreaterThan(0, $rep->declLine); + self::assertStringContainsString('<', $rep->label); + } + }); + } + + public function testSelectionIsDeterministic(): void + { + $this->withCompiled('compile/multi_type', function (CompiledWorkspace $w): void { + $first = RepresentativeSelector::select($w->registry, $w->generatedDir); + $second = RepresentativeSelector::select($w->registry, $w->generatedDir); + + $fqns = static fn (array $reps): array + => array_map(static fn (Representative $r): string => $r->generatedFqn, $reps); + + // Stable order, and each pick is the lexicographically smallest generatedFqn. + self::assertSame($fqns($first), $fqns($second)); + $sorted = $fqns($first); + $resorted = $sorted; + sort($resorted); + self::assertSame($resorted, $sorted, 'representatives must be sorted by generatedFqn'); + }); + } + + public function testEachTemplatePicksItsLexicographicallySmallestSpecialization(): void + { + $this->withCompiled('compile/multi_type', function (CompiledWorkspace $w): void { + // The expected pick per template: min generatedFqn among its instantiations. + $minByTemplate = []; + foreach ($w->registry->instantiations() as $inst) { + $current = $minByTemplate[$inst->templateFqn] ?? null; + if ($current === null || strcmp($inst->generatedFqn, $current) < 0) { + $minByTemplate[$inst->templateFqn] = $inst->generatedFqn; + } + } + + foreach (RepresentativeSelector::select($w->registry, $w->generatedDir) as $rep) { + self::assertSame( + $minByTemplate[$rep->templateFqn], + $rep->generatedFqn, + "template {$rep->templateFqn} should be represented by its smallest generatedFqn", + ); + } + }); + } + + public function testFilePathNormalisesTrailingSlashOnGeneratedDir(): void + { + $this->withCompiled('check/body_type_error', function (CompiledWorkspace $w): void { + $reps = RepresentativeSelector::select($w->registry, $w->generatedDir . '/'); + + self::assertStringNotContainsString('//', substr($reps[0]->filePath, 1)); + self::assertFileExists($reps[0]->filePath); + }); + } + + public function testRepresentativeMapsToTemplateDeclaration(): void + { + $this->withCompiled('check/body_type_error', function (CompiledWorkspace $w): void { + $reps = RepresentativeSelector::select($w->registry, $w->generatedDir); + + self::assertCount(1, $reps); + $box = $reps[0]; + self::assertSame('App\\Check\\BodyTypeError\\Box', $box->templateFqn); + self::assertSame('App\\Check\\BodyTypeError\\Box', $box->label); + self::assertStringEndsWith('Box.xphp', $box->declFile); + self::assertSame(7, $box->declLine); // `class Box` line + }); + } + + /** @param callable(CompiledWorkspace): void $assertions */ + private function withCompiled(string $fixture, callable $assertions): void + { + $sourceDir = realpath(__DIR__ . '/../fixture/' . $fixture . '/source') + ?: throw new RuntimeException("fixture missing: {$fixture}"); + $sources = (new NativeFileFinder()) + ->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + + $workspace = CompiledWorkspace::compile( + $this->compiler(), + $sources, + $sourceDir, + sys_get_temp_dir() . '/xphp-repsel-' . uniqid('', true), + ); + try { + $assertions($workspace); + } finally { + $workspace->cleanup(); + } + } + + private function compiler(): Compiler + { + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + + return new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + } +} diff --git a/test/fixture/check/body_type_error/source/Box.xphp b/test/fixture/check/body_type_error/source/Box.xphp new file mode 100644 index 0000000..3cde25b --- /dev/null +++ b/test/fixture/check/body_type_error/source/Box.xphp @@ -0,0 +1,26 @@ + +{ + public function __construct(public T $item) + { + } + + public function get(): T + { + return $this->item; + } + + public function broken(): T + { + // The generics are valid, so `xphp check`'s own phase passes and `xphp + // compile` succeeds. But after specialization (T -> int) this method + // returns a string where an int is declared — a body-level type error + // only PHPStan, run over the concrete output, can see. + return 'not a valid value'; + } +} diff --git a/test/fixture/check/body_type_error/source/Use.xphp b/test/fixture/check/body_type_error/source/Use.xphp new file mode 100644 index 0000000..a892bf9 --- /dev/null +++ b/test/fixture/check/body_type_error/source/Use.xphp @@ -0,0 +1,9 @@ +(1); +$box->get(); +$box->broken(); From cd721d4ec430fa4519408cc5ac8e0880c51fe9ca Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 18 Jun 2026 13:40:37 +0000 Subject: [PATCH 019/114] feat(check): map PHPStan findings to .xphp and wire the pass into `check` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- infection.json5 | 5 + src/Console/ApplicationConsole.php | 3 +- src/Console/Command/CheckCommand.php | 36 +++- src/Diagnostics/Renderer/GithubRenderer.php | 8 +- src/StaticAnalysis/PhpStanResultMapper.php | 70 +++++++ src/StaticAnalysis/StaticAnalysisGate.php | 121 ++++++++++++ test/Console/CheckCommandPhpStanTest.php | 145 ++++++++++++++ test/Console/CheckCommandTest.php | 9 +- test/Diagnostics/Renderer/RendererTest.php | 3 +- .../PhpStanResultMapperTest.php | 97 ++++++++++ .../StaticAnalysisGatePureTest.php | 59 ++++++ .../StaticAnalysis/StaticAnalysisGateTest.php | 180 ++++++++++++++++++ test/fixture/check/phpstan-level5.neon | 2 + 13 files changed, 729 insertions(+), 9 deletions(-) create mode 100644 src/StaticAnalysis/PhpStanResultMapper.php create mode 100644 src/StaticAnalysis/StaticAnalysisGate.php create mode 100644 test/Console/CheckCommandPhpStanTest.php create mode 100644 test/StaticAnalysis/PhpStanResultMapperTest.php create mode 100644 test/StaticAnalysis/StaticAnalysisGatePureTest.php create mode 100644 test/StaticAnalysis/StaticAnalysisGateTest.php create mode 100644 test/fixture/check/phpstan-level5.neon diff --git a/infection.json5 b/infection.json5 index c0d36a2..1c5684e 100644 --- a/infection.json5 +++ b/infection.json5 @@ -1,5 +1,10 @@ { "$schema": "vendor/infection/infection/resources/schema.json", + // Per-mutant timeout. Raised from the 10s default because the StaticAnalysis + // (PHPStan) tests shell out to a real `phpstan` subprocess, so a mutant whose + // covering set includes them needs well over 10s to run — otherwise it false- + // times-out instead of being killed by the fast pure tests in the same set. + "timeout": 120, "source": { "directories": [ "src" diff --git a/src/Console/ApplicationConsole.php b/src/Console/ApplicationConsole.php index 9daf74f..901a09d 100644 --- a/src/Console/ApplicationConsole.php +++ b/src/Console/ApplicationConsole.php @@ -12,6 +12,7 @@ use XPHP\FileSystem\FileFinder; use XPHP\FileSystem\FileReader; use XPHP\FileSystem\FileWriter; +use XPHP\StaticAnalysis\StaticAnalysisGate; use XPHP\Transpiler\Monomorphize\Compiler; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\SpecializedClassGenerator; @@ -47,6 +48,6 @@ public function __construct( ); $this->addCommand(new CompileCommand($fileFinder, $compiler)); - $this->addCommand(new CheckCommand($fileFinder, $compiler)); + $this->addCommand(new CheckCommand($fileFinder, $compiler, new StaticAnalysisGate($compiler))); } } diff --git a/src/Console/Command/CheckCommand.php b/src/Console/Command/CheckCommand.php index 308339e..7030f9b 100644 --- a/src/Console/Command/CheckCommand.php +++ b/src/Console/Command/CheckCommand.php @@ -15,14 +15,20 @@ use XPHP\Diagnostics\Renderer\JsonRenderer; use XPHP\Diagnostics\Renderer\TextRenderer; use XPHP\FileSystem\FileFinder; +use XPHP\StaticAnalysis\StaticAnalysisGate; use XPHP\Transpiler\Monomorphize\Compiler; /** - * `xphp check [--format=text|json|github]` + * `xphp check [--format=text|json|github] [--no-phpstan] + * [--phpstan-bin=PATH] [--phpstan-config=PATH]` * * Validates the generic code without emitting any output, reporting every - * diagnostic in one run. Exit codes: 0 = clean, 1 = at least one error-severity - * diagnostic, 2 = operational failure (bad source dir or unknown format). + * diagnostic in one run. When the generic checks pass, it then runs the + * consumer's PHPStan over the compiled (concrete) output and merges those + * findings into the same report — one gate, one exit code. + * + * Exit codes: 0 = clean, 1 = at least one error-severity diagnostic, 2 = + * operational failure (bad source dir or unknown format). */ #[AsCommand('check', 'Validate generic .xphp code and report diagnostics without emitting output')] final class CheckCommand extends Command @@ -30,6 +36,7 @@ final class CheckCommand extends Command public function __construct( private readonly FileFinder $fileFinder, private readonly Compiler $compiler, + private readonly StaticAnalysisGate $staticAnalysisGate, ) { parent::__construct(); } @@ -38,7 +45,10 @@ protected function configure(): void { $this ->addArgument('source', InputArgument::REQUIRED, 'Directory containing .xphp source files') - ->addOption('format', null, InputOption::VALUE_REQUIRED, 'Output format: text, json, or github', 'text'); + ->addOption('format', null, InputOption::VALUE_REQUIRED, 'Output format: text, json, or github', 'text') + ->addOption('no-phpstan', null, InputOption::VALUE_NONE, 'Skip the PHPStan pass over the compiled output') + ->addOption('phpstan-bin', null, InputOption::VALUE_REQUIRED, 'Path to the PHPStan binary (default: vendor/bin/phpstan, then $PATH)') + ->addOption('phpstan-config', null, InputOption::VALUE_REQUIRED, 'Path to the PHPStan config (default: auto-detect phpstan.neon[.dist])'); } protected function execute( @@ -68,6 +78,24 @@ protected function execute( $diagnostics = $this->compiler->check($sources); + // Only layer PHPStan on when the generic checks pass: invalid generics can't be + // compiled to the concrete output PHPStan needs, and reporting both at once would + // just be noise on top of the real (generic) errors. + if (!$diagnostics->hasErrors() && $input->getOption('no-phpstan') !== true) { + $binOption = $input->getOption('phpstan-bin'); + $configOption = $input->getOption('phpstan-config'); + $findings = $this->staticAnalysisGate->analyze( + $sources, + $sourceDir, + getcwd() ?: '.', + is_string($binOption) ? $binOption : null, + is_string($configOption) ? $configOption : null, + ); + foreach ($findings as $finding) { + $diagnostics->add($finding); + } + } + $output->write($renderer->render($diagnostics->all())); return $diagnostics->hasErrors() ? self::FAILURE : self::SUCCESS; diff --git a/src/Diagnostics/Renderer/GithubRenderer.php b/src/Diagnostics/Renderer/GithubRenderer.php index 76b9151..8623eba 100644 --- a/src/Diagnostics/Renderer/GithubRenderer.php +++ b/src/Diagnostics/Renderer/GithubRenderer.php @@ -38,7 +38,13 @@ public function render(array $diagnostics): string } $prefix = $props === [] ? '::' . $command : '::' . $command . ' ' . implode(',', $props); - $lines[] = $prefix . '::' . self::escapeData($d->message); + // GitHub annotations carry no separate "triggered by" field, so fold the + // instantiation that surfaced a (PHPStan) finding into the message — it's + // the key context for a body error reported at a template declaration. + $message = $d->triggeredBy !== null + ? $d->message . ' (triggered by ' . $d->triggeredBy . ')' + : $d->message; + $lines[] = $prefix . '::' . self::escapeData($message); } return $lines === [] ? '' : implode(PHP_EOL, $lines) . PHP_EOL; diff --git a/src/StaticAnalysis/PhpStanResultMapper.php b/src/StaticAnalysis/PhpStanResultMapper.php new file mode 100644 index 0000000..3de5cab --- /dev/null +++ b/src/StaticAnalysis/PhpStanResultMapper.php @@ -0,0 +1,70 @@ + $findings + * @param list $representatives + * @return list + */ + public static function map(array $findings, array $representatives): array + { + $byFile = []; + foreach ($representatives as $representative) { + $byFile[$representative->filePath] = $representative; + } + + $diagnostics = []; + foreach ($findings as $finding) { + $representative = $byFile[$finding->file] ?? null; + + $diagnostics[] = $representative !== null + ? new Diagnostic( + Severity::Error, + self::code($finding), + $finding->message, + new SourceLocation($representative->declFile, $representative->declLine), + $representative->label, + DiagnosticSource::PhpStan, + ) + : new Diagnostic( + Severity::Error, + self::code($finding), + $finding->message, + null, + null, + DiagnosticSource::PhpStan, + ); + } + + return $diagnostics; + } + + private static function code(PhpStanFinding $finding): string + { + return $finding->identifier !== null ? 'phpstan.' . $finding->identifier : 'phpstan.error'; + } +} diff --git a/src/StaticAnalysis/StaticAnalysisGate.php b/src/StaticAnalysis/StaticAnalysisGate.php new file mode 100644 index 0000000..aa6f792 --- /dev/null +++ b/src/StaticAnalysis/StaticAnalysisGate.php @@ -0,0 +1,121 @@ + + */ + public function analyze( + FilepathArray $sources, + string $sourceDir, + string $workingDir, + ?string $explicitBin, + ?string $explicitConfig, + ): array { + $bin = PhpStanLocator::fromEnvironment($workingDir)->locate($explicitBin); + if ($bin === null) { + return [new Diagnostic( + Severity::Warning, + self::CODE_UNAVAILABLE, + 'PHPStan was not found (looked for --phpstan-bin, then vendor/bin/phpstan, then $PATH); ' + . 'skipping static analysis. Pass --no-phpstan to silence this.', + null, + null, + DiagnosticSource::PhpStan, + )]; + } + + $config = (new PhpStanConfigResolver($workingDir))->resolve($explicitConfig); + $workspace = CompiledWorkspace::inTempDir($this->compiler, $sources, $sourceDir, sys_get_temp_dir()); + try { + $representatives = RepresentativeSelector::select($workspace->registry, $workspace->generatedDir); + if ($representatives === []) { + // No generic instantiations → no specialized code for PHPStan to add over + // the generic checks. Nothing to do. + return []; + } + + $result = (new PhpStanRunner($bin))->run( + array_map(static fn (Representative $r): string => $r->filePath, $representatives), + self::buildScanDirectories($workspace->distDir, $workspace->generatedDir, $workingDir), + $config, + $workspace->root . '/phpstan-ephemeral.neon', + ); + + if (!$result->ranOk) { + return [new Diagnostic( + Severity::Warning, + self::CODE_RUN_FAILED, + 'PHPStan could not complete: ' . self::summarize($result->errorOutput), + null, + null, + DiagnosticSource::PhpStan, + )]; + } + + return PhpStanResultMapper::map($result->findings, $representatives); + } finally { + $workspace->cleanup(); + } + } + + /** + * Dirs PHPStan scans for symbol resolution: the compiled output plus the + * consumer's vendor (when present) so generated code resolves its + * dependencies. Pure/static so the vendor-inclusion logic is unit-testable. + * + * @return list + */ + public static function buildScanDirectories(string $distDir, string $generatedDir, string $workingDir): array + { + $directories = [$distDir, $generatedDir]; + + $vendorDir = $workingDir . '/vendor'; + if (is_dir($vendorDir)) { + $directories[] = $vendorDir; + } + + return $directories; + } + + /** + * Cap a PHPStan failure blob so a fatal that dumps a long trace (or a flood of + * "unknown class" lines) doesn't become a single unreadable diagnostic message. + */ + public static function summarize(?string $errorOutput, int $max = 500): string + { + if ($errorOutput === null || $errorOutput === '') { + return 'unknown error'; + } + + return strlen($errorOutput) > $max ? substr($errorOutput, 0, $max) . '…' : $errorOutput; + } +} diff --git a/test/Console/CheckCommandPhpStanTest.php b/test/Console/CheckCommandPhpStanTest.php new file mode 100644 index 0000000..2412113 --- /dev/null +++ b/test/Console/CheckCommandPhpStanTest.php @@ -0,0 +1,145 @@ +bin = $bin; + } + + public function testReportsBodyTypeErrorAtTheTemplateDeclaration(): void + { + $tester = $this->tester(); + $exit = $tester->execute([ + 'source' => $this->fixtureDir('body_type_error'), + '--phpstan-bin' => $this->bin, + '--phpstan-config' => $this->level5Config(), + '--format' => 'json', + ]); + + self::assertSame(1, $exit); + + /** @var array{diagnostics: list} $decoded */ + $decoded = json_decode($tester->getDisplay(), true, flags: JSON_THROW_ON_ERROR); + + $phpstan = array_values(array_filter( + $decoded['diagnostics'], + static fn (array $d): bool => $d['source'] === 'phpstan', + )); + self::assertNotEmpty($phpstan, 'expected at least one phpstan diagnostic'); + + $boxError = array_values(array_filter( + $phpstan, + static fn (array $d): bool => str_contains($d['message'], 'should return int but returns string'), + )); + self::assertCount(1, $boxError); + self::assertStringEndsWith('Box.xphp', $boxError[0]['file'] ?? ''); + self::assertSame(7, $boxError[0]['line']); + self::assertStringStartsWith('phpstan.', $boxError[0]['code']); + } + + public function testNoPhpstanFlagSkipsThePassAndStaysClean(): void + { + $tester = $this->tester(); + $exit = $tester->execute([ + 'source' => $this->fixtureDir('body_type_error'), + '--no-phpstan' => true, + ]); + + self::assertSame(0, $exit); + self::assertStringContainsString('No problems found', $tester->getDisplay()); + } + + public function testMissingBinaryWarnsButDoesNotFailTheGate(): void + { + $tester = $this->tester(); + $exit = $tester->execute([ + 'source' => $this->fixtureDir('body_type_error'), + '--phpstan-bin' => '/definitely/not/a/real/phpstan', + ]); + + self::assertSame(0, $exit); // a missing optional tool is a Warning, not a failure + self::assertStringContainsString('PHPStan was not found', $tester->getDisplay()); + } + + public function testExplicitConfigIsPassedThroughToPhpStan(): void + { + // A config that includes a non-existent file fatals PHPStan → a run-failure + // Warning (exit 0), proving --phpstan-config is honoured rather than ignored. + $badConfig = sys_get_temp_dir() . '/xphp-cmd-badcfg-' . uniqid('', true) . '.neon'; + file_put_contents($badConfig, "includes:\n - /no/such/file.neon\n"); + + try { + $tester = $this->tester(); + $exit = $tester->execute([ + 'source' => $this->fixtureDir('body_type_error'), + '--phpstan-bin' => $this->bin, + '--phpstan-config' => $badConfig, + ]); + + self::assertSame(0, $exit); + self::assertStringContainsString('PHPStan could not complete', $tester->getDisplay()); + } finally { + unlink($badConfig); + } + } + + private function tester(): CommandTester + { + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + $compiler = new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + + return new CommandTester( + new CheckCommand(new NativeFileFinder(), $compiler, new StaticAnalysisGate($compiler)), + ); + } + + private function fixtureDir(string $fixture): string + { + return realpath(__DIR__ . '/../fixture/check/' . $fixture . '/source') + ?: throw new RuntimeException("Fixture missing: {$fixture}"); + } + + private function level5Config(): string + { + return realpath(__DIR__ . '/../fixture/check/phpstan-level5.neon') + ?: throw new RuntimeException('level5 config fixture missing'); + } +} diff --git a/test/Console/CheckCommandTest.php b/test/Console/CheckCommandTest.php index b047e5a..304c3ad 100644 --- a/test/Console/CheckCommandTest.php +++ b/test/Console/CheckCommandTest.php @@ -12,6 +12,7 @@ use XPHP\FileSystem\FileFinder\NativeFileFinder; use XPHP\FileSystem\FileReader\NativeFileReader; use XPHP\FileSystem\FileWriter\NativeFileWriter; +use XPHP\StaticAnalysis\StaticAnalysisGate; use XPHP\Transpiler\Monomorphize\Compiler; use XPHP\Transpiler\Monomorphize\Specializer; use XPHP\Transpiler\Monomorphize\SpecializedClassGenerator; @@ -21,8 +22,10 @@ final class CheckCommandTest extends TestCase { public function testCleanSourcesExitZero(): void { + // --no-phpstan isolates the generic-check (Phase 1) contract from the + // PHPStan pass, which is exercised separately in CheckCommandPhpStanTest. $tester = $this->tester(); - $exit = $tester->execute(['source' => $this->fixtureDir('clean')]); + $exit = $tester->execute(['source' => $this->fixtureDir('clean'), '--no-phpstan' => true]); self::assertSame(0, $exit); self::assertStringContainsString('No problems found', $tester->getDisplay()); @@ -101,7 +104,9 @@ private function tester(): CommandTester $printer, ); - return new CommandTester(new CheckCommand(new NativeFileFinder(), $compiler)); + return new CommandTester( + new CheckCommand(new NativeFileFinder(), $compiler, new StaticAnalysisGate($compiler)), + ); } private function fixtureDir(string $fixture): string diff --git a/test/Diagnostics/Renderer/RendererTest.php b/test/Diagnostics/Renderer/RendererTest.php index 18cbb97..e64f2c6 100644 --- a/test/Diagnostics/Renderer/RendererTest.php +++ b/test/Diagnostics/Renderer/RendererTest.php @@ -101,7 +101,8 @@ public function testGithubRender(): void { $expected = implode(PHP_EOL, [ '::error file=/src/Box.xphp,line=7,col=3::bad bound', - '::warning::maybe', + // The second diagnostic carries triggeredBy, folded into the message. + '::warning::maybe (triggered by App\\Box)', ]) . PHP_EOL; self::assertSame($expected, (new GithubRenderer())->render($this->sample())); diff --git a/test/StaticAnalysis/PhpStanResultMapperTest.php b/test/StaticAnalysis/PhpStanResultMapperTest.php new file mode 100644 index 0000000..0610238 --- /dev/null +++ b/test/StaticAnalysis/PhpStanResultMapperTest.php @@ -0,0 +1,97 @@ +', + '/project/src/Box.xphp', + 7, + ); + $finding = new PhpStanFinding($rep->filePath, 15, 'should return int but returns string', 'return.type'); + + $diagnostics = PhpStanResultMapper::map([$finding], [$rep]); + + self::assertCount(1, $diagnostics); + $d = $diagnostics[0]; + self::assertSame(Severity::Error, $d->severity); + self::assertSame('phpstan.return.type', $d->code); + self::assertSame('should return int but returns string', $d->message); + self::assertSame(DiagnosticSource::PhpStan, $d->source); + self::assertSame('App\\Box', $d->triggeredBy); + self::assertNotNull($d->location); + // Anchored at the TEMPLATE declaration, not the generated file/line. + self::assertSame('/project/src/Box.xphp', $d->location->file); + self::assertSame(7, $d->location->line); + } + + public function testFindingWithoutIdentifierGetsGenericCode(): void + { + $rep = $this->representative(); + $finding = new PhpStanFinding($rep->filePath, 3, 'some error', null); + + $diagnostics = PhpStanResultMapper::map([$finding], [$rep]); + + self::assertSame('phpstan.error', $diagnostics[0]->code); + } + + public function testUnmatchedFindingIsSurfacedWithoutLocationOrTriggeredBy(): void + { + $rep = $this->representative(); + // A finding in a file that is NOT a known representative (defensive path): + // surfaced (never dropped) but with no location, so the throwaway temp-dir + // path it came from never leaks into the report. + $finding = new PhpStanFinding('/tmp/ws/dist/Other.php', 42, 'orphan error', 'foo.bar'); + + $diagnostics = PhpStanResultMapper::map([$finding], [$rep]); + + self::assertCount(1, $diagnostics); + $d = $diagnostics[0]; + self::assertSame('orphan error', $d->message); + self::assertNull($d->triggeredBy); + self::assertNull($d->location); + } + + public function testEmptyFindingsMapToNoDiagnostics(): void + { + self::assertSame([], PhpStanResultMapper::map([], [$this->representative()])); + } + + public function testMultipleFindingsEachBecomeADiagnostic(): void + { + $rep = $this->representative(); + $findings = [ + new PhpStanFinding($rep->filePath, 1, 'first', 'a.b'), + new PhpStanFinding($rep->filePath, 2, 'second', 'c.d'), + ]; + + $diagnostics = PhpStanResultMapper::map($findings, [$rep]); + + self::assertCount(2, $diagnostics); + self::assertSame(['first', 'second'], array_map(static fn ($d): string => $d->message, $diagnostics)); + } + + private function representative(): Representative + { + return new Representative( + 'XPHP\\Generated\\App\\Box\\T_abc', + '/tmp/ws/cache/Generated/App/Box/T_abc.php', + 'App\\Box', + 'App\\Box', + '/project/src/Box.xphp', + 7, + ); + } +} diff --git a/test/StaticAnalysis/StaticAnalysisGatePureTest.php b/test/StaticAnalysis/StaticAnalysisGatePureTest.php new file mode 100644 index 0000000..a4c333c --- /dev/null +++ b/test/StaticAnalysis/StaticAnalysisGatePureTest.php @@ -0,0 +1,59 @@ +workingDir = realpath(__DIR__ . '/../..') ?: throw new RuntimeException('repo root missing'); + $bin = realpath($this->workingDir . '/vendor/bin/phpstan'); + if ($bin === false) { + self::markTestSkipped('phpstan binary not installed (vendor/bin/phpstan)'); + } + $this->bin = $bin; + } + + public function testReportsBodyTypeErrorMappedToTemplateDeclaration(): void + { + $diagnostics = $this->gate()->analyze( + ...$this->fixtureArgs('check/body_type_error'), + explicitBin: $this->bin, + explicitConfig: $this->level5Config(), + ); + + self::assertCount(1, $diagnostics); + $d = $diagnostics[0]; + self::assertSame(Severity::Error, $d->severity); + self::assertSame(DiagnosticSource::PhpStan, $d->source); + self::assertStringContainsString('should return int but returns string', $d->message); + self::assertSame('App\\Check\\BodyTypeError\\Box', $d->triggeredBy); + self::assertNotNull($d->location); + self::assertStringEndsWith('Box.xphp', $d->location->file); + self::assertSame(7, $d->location->line); + } + + public function testWorkspaceIsCleanedUpAfterAnalysis(): void + { + $before = glob(sys_get_temp_dir() . '/xphp-check-*') ?: []; + + $this->gate()->analyze( + ...$this->fixtureArgs('check/body_type_error'), + explicitBin: $this->bin, + explicitConfig: null, + ); + + $after = glob(sys_get_temp_dir() . '/xphp-check-*') ?: []; + // The finally-cleanup must leave no workspace behind (guards UnwrapFinally). + self::assertSame($before, $after); + } + + public function testCleanSourcesProduceNoDiagnostics(): void + { + $diagnostics = $this->gate()->analyze( + ...$this->fixtureArgs('compile/box_generic'), + explicitBin: $this->bin, + explicitConfig: $this->level5Config(), + ); + + self::assertSame([], $diagnostics); + } + + public function testMissingBinaryYieldsNonFailingWarning(): void + { + $diagnostics = $this->gate()->analyze( + ...$this->fixtureArgs('check/body_type_error'), + explicitBin: '/definitely/not/a/real/phpstan', + explicitConfig: null, + ); + + self::assertCount(1, $diagnostics); + self::assertSame(StaticAnalysisGate::CODE_UNAVAILABLE, $diagnostics[0]->code); + self::assertSame(Severity::Warning, $diagnostics[0]->severity); + self::assertFalse($diagnostics[0]->severity->isFailing()); + self::assertStringContainsString('PHPStan was not found', $diagnostics[0]->message); + } + + public function testFailedRunYieldsNonFailingWarning(): void + { + $badConfig = sys_get_temp_dir() . '/xphp-bad-config-' . uniqid('', true) . '.neon'; + file_put_contents($badConfig, "includes:\n - /no/such/file.neon\n"); + + try { + $diagnostics = $this->gate()->analyze( + ...$this->fixtureArgs('check/body_type_error'), + explicitBin: $this->bin, + explicitConfig: $badConfig, + ); + + self::assertCount(1, $diagnostics); + self::assertSame(StaticAnalysisGate::CODE_RUN_FAILED, $diagnostics[0]->code); + self::assertSame(Severity::Warning, $diagnostics[0]->severity); + self::assertStringStartsWith('PHPStan could not complete: ', $diagnostics[0]->message); + } finally { + unlink($badConfig); + } + } + + public function testSourcesWithoutGenericInstantiationsProduceNoDiagnostics(): void + { + // A plain (non-generic) source has no specializations, so there's nothing + // for PHPStan to add over the generic checks. + $dir = sys_get_temp_dir() . '/xphp-plain-' . uniqid('', true); + mkdir($dir . '/source', 0o755, true); + file_put_contents( + $dir . '/source/Plain.xphp', + "find($dir . '/source') + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + + $diagnostics = $this->gate()->analyze($sources, $dir . '/source', $this->workingDir, $this->bin, null); + + self::assertSame([], $diagnostics); + } finally { + unlink($dir . '/source/Plain.xphp'); + rmdir($dir . '/source'); + rmdir($dir); + } + } + + private function level5Config(): string + { + // A fixed, minimal consumer config so the body-error assertions don't depend + // on (or drift with) the repo's own phpstan.neon picked up via getcwd(). + return realpath(__DIR__ . '/../fixture/check/phpstan-level5.neon') + ?: throw new RuntimeException('level5 config fixture missing'); + } + + /** @return array{FilepathArray, string, string} */ + private function fixtureArgs(string $fixture): array + { + $sourceDir = realpath(__DIR__ . '/../fixture/' . $fixture . '/source') + ?: throw new RuntimeException("fixture missing: {$fixture}"); + $sources = (new NativeFileFinder()) + ->find($sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + + return [$sources, $sourceDir, $this->workingDir]; + } + + private function gate(): StaticAnalysisGate + { + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + $compiler = new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + + return new StaticAnalysisGate($compiler); + } +} diff --git a/test/fixture/check/phpstan-level5.neon b/test/fixture/check/phpstan-level5.neon new file mode 100644 index 0000000..a2b3ad8 --- /dev/null +++ b/test/fixture/check/phpstan-level5.neon @@ -0,0 +1,2 @@ +parameters: + level: 5 From b85392ad1f480c055238f54e61e7c36f1fe71466 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 18 Jun 2026 13:47:59 +0000 Subject: [PATCH 020/114] test(check): group the PHPStan-pass tests and split them out of test/unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Makefile | 16 ++++++++++++---- test/Console/CheckCommandPhpStanTest.php | 2 ++ test/StaticAnalysis/PhpStanRunnerTest.php | 2 ++ test/StaticAnalysis/StaticAnalysisGateTest.php | 2 ++ 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index aad524a..5d6ff92 100644 --- a/Makefile +++ b/Makefile @@ -7,11 +7,19 @@ # targets here. .PHONY: test/unit -# Default runtime is PHP 8.4 (composer requires ^8.4). Tests exercising -# newer-PHP syntax are tagged `@group php85` and excluded here; they run -# on an 8.5 runtime via `make test/unit/php85`. +# Default runtime is PHP 8.4 (composer requires ^8.4). Two groups are excluded +# here: `php85` (newer-PHP syntax; runs on 8.5 via `make test/unit/php85`) and +# `phpstan` (the `xphp check` PHPStan pass, which shells out to a real phpstan +# subprocess and is slow; runs via `make test/phpstan`). test/unit: - php vendor/bin/phpunit --exclude-group php85 + php vendor/bin/phpunit --exclude-group php85 --exclude-group phpstan + +.PHONY: test/phpstan +# The `xphp check` PHPStan-integration tests (tagged `@group phpstan`). They +# shell out to the consumer's phpstan binary and self-skip when vendor/bin/phpstan +# is absent. NOTE: distinct from `lint/phpstan`, which runs PHPStan over src/. +test/phpstan: + php vendor/bin/phpunit --group phpstan .PHONY: test/unit/php85 # Runs only the PHP 8.5-specific syntax tests (e.g. the pipe operator). diff --git a/test/Console/CheckCommandPhpStanTest.php b/test/Console/CheckCommandPhpStanTest.php index 2412113..3ad84db 100644 --- a/test/Console/CheckCommandPhpStanTest.php +++ b/test/Console/CheckCommandPhpStanTest.php @@ -6,6 +6,7 @@ use PhpParser\ParserFactory; use PhpParser\PrettyPrinter\Standard as StandardPrinter; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use RuntimeException; use Symfony\Component\Console\Tester\CommandTester; @@ -22,6 +23,7 @@ * End-to-end coverage of the PHPStan pass wired into `xphp check`. Skips when no * phpstan binary is installed (mirrors the @group php85 self-skip convention). */ +#[Group('phpstan')] final class CheckCommandPhpStanTest extends TestCase { private string $bin; diff --git a/test/StaticAnalysis/PhpStanRunnerTest.php b/test/StaticAnalysis/PhpStanRunnerTest.php index 3e78b8f..dfb99e6 100644 --- a/test/StaticAnalysis/PhpStanRunnerTest.php +++ b/test/StaticAnalysis/PhpStanRunnerTest.php @@ -6,6 +6,7 @@ use PhpParser\ParserFactory; use PhpParser\PrettyPrinter\Standard as StandardPrinter; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use RuntimeException; use XPHP\FileSystem\FileFinder\NativeFileFinder; @@ -16,6 +17,7 @@ use XPHP\Transpiler\Monomorphize\Specializer; use XPHP\Transpiler\Monomorphize\XphpSourceParser; +#[Group('phpstan')] final class PhpStanRunnerTest extends TestCase { private string $phpstanBin; diff --git a/test/StaticAnalysis/StaticAnalysisGateTest.php b/test/StaticAnalysis/StaticAnalysisGateTest.php index e140def..85f3e2d 100644 --- a/test/StaticAnalysis/StaticAnalysisGateTest.php +++ b/test/StaticAnalysis/StaticAnalysisGateTest.php @@ -6,6 +6,7 @@ use PhpParser\ParserFactory; use PhpParser\PrettyPrinter\Standard as StandardPrinter; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use RuntimeException; use XPHP\Diagnostics\DiagnosticSource; @@ -19,6 +20,7 @@ use XPHP\Transpiler\Monomorphize\Specializer; use XPHP\Transpiler\Monomorphize\XphpSourceParser; +#[Group('phpstan')] final class StaticAnalysisGateTest extends TestCase { private string $bin; From 8a8e24ef29f4187b0a7f387052058c285d54dc2f Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 18 Jun 2026 13:57:13 +0000 Subject: [PATCH 021/114] docs(check): document the PHPStan pass; add CI job + changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .github/workflows/ci-core.yml | 23 ++++++++++++++++++++++ CHANGELOG.md | 19 ++++++++++++++++++ CONTRIBUTING.md | 14 +++++++++++--- Makefile | 6 +++--- README.md | 4 +++- docs/errors.md | 36 +++++++++++++++++++++++++++++++++++ test/smoke/check.sh | 5 ++++- 7 files changed, 99 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index 46bc492..9aef938 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -66,6 +66,29 @@ jobs: - name: Run PHPStan run: make lint/phpstan + phpstan-pass: + # The `xphp check` PHPStan-integration tests (@group phpstan) shell out to a + # real phpstan binary, so they're excluded from `make test/unit` and run here. + # `composer install` provides vendor/bin/phpstan (a require-dev dependency). + name: PHPStan pass (xphp check) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP 8.4 + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: dom, json, mbstring, tokenizer + coverage: none + tools: composer:v2 + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + - name: Run the PHPStan-pass tests + run: make test/phpstan-pass + check: # End-to-end self-test of the `xphp check` gate: runs the real bin/xphp # binary against the check fixtures and asserts the 0/1/2 exit contract. diff --git a/CHANGELOG.md b/CHANGELOG.md index 63820e0..e473069 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to `xphp` are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **`xphp check`** — a validate-without-emitting CI gate. It runs every generic + validation `xphp compile` does (bounds, variance, defaults, missing/duplicate + generics, unsupported closures), but collects **all** problems in one run — + each as a structured diagnostic with a `file:line` — instead of aborting on the + first. Exit codes: `0` clean, `1` ≥1 error, `2` operational failure. + `--format=text|json|github` (the `github` format emits PR annotations). +- **PHPStan over the compiled output.** When the generic checks pass, `xphp check` + compiles to a throwaway directory, runs **your** PHPStan over the concrete + (monomorphized) output, and maps each finding back to the originating `.xphp` + template declaration — naming the concrete instantiation that surfaced it. One + config (your `phpstan.neon` drives level/rules), one gate, one exit code. + `phpstan/phpstan` stays optional and is never bundled in the PHAR; a missing + binary or a failed run is a non-failing Warning. Opt out with `--no-phpstan`; + override discovery with `--phpstan-bin` / `--phpstan-config`. + ## [0.2.0] The first feature release on top of the core monomorphization pipeline. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 84b9757..3539391 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,9 +3,10 @@ ## Test ```bash -make test/unit # PHPUnit on the default PHP 8.4 runtime -make test/unit/php85 # only tests tagged `@group php85`; needs a PHP 8.5 runtime -make test/mutation # Infection, MSI under a 95 % gate +make test/unit # PHPUnit on the default PHP 8.4 runtime +make test/unit/php85 # only tests tagged `@group php85`; needs a PHP 8.5 runtime +make test/phpstan-pass # only tests tagged `@group phpstan` (the `xphp check` PHPStan pass) +make test/mutation # Infection, MSI under a 95 % gate ``` The supported runtime is PHP `^8.4`. Tests that exercise newer-PHP syntax @@ -13,6 +14,13 @@ The supported runtime is PHP `^8.4`. Tests that exercise newer-PHP syntax excludes them and they self-skip via `#[RequiresPhp]` off an 8.5 host. CI runs them in a dedicated PHP 8.5 job. +The `xphp check` PHPStan-pass tests are tagged `@group phpstan` — they shell out +to a real phpstan subprocess (slow), so `make test/unit` excludes them and they +self-skip when `vendor/bin/phpstan` is absent (the same self-skip idea as +`@group php85`). CI runs them in a dedicated job; locally use `make +test/phpstan-pass`. (Not to be confused with `make lint/phpstan`, which runs +PHPStan over `src/`.) + No PHP 8.5 locally? Run them in the bundled 8.5 container: ```bash diff --git a/Makefile b/Makefile index 5d6ff92..73a969f 100644 --- a/Makefile +++ b/Makefile @@ -10,15 +10,15 @@ # Default runtime is PHP 8.4 (composer requires ^8.4). Two groups are excluded # here: `php85` (newer-PHP syntax; runs on 8.5 via `make test/unit/php85`) and # `phpstan` (the `xphp check` PHPStan pass, which shells out to a real phpstan -# subprocess and is slow; runs via `make test/phpstan`). +# subprocess and is slow; runs via `make test/phpstan-pass`). test/unit: php vendor/bin/phpunit --exclude-group php85 --exclude-group phpstan -.PHONY: test/phpstan +.PHONY: test/phpstan-pass # The `xphp check` PHPStan-integration tests (tagged `@group phpstan`). They # shell out to the consumer's phpstan binary and self-skip when vendor/bin/phpstan # is absent. NOTE: distinct from `lint/phpstan`, which runs PHPStan over src/. -test/phpstan: +test/phpstan-pass: php vendor/bin/phpunit --group phpstan .PHONY: test/unit/php85 diff --git a/README.md b/README.md index a01c61f..65bfe29 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,9 @@ vendor/bin/xphp check src # exit 1 if any error; --format=text|json|g ``` `check` runs all of xphp's generic validation (the specialization-loop guards -aside); you still run `compile` to emit the PHP. See +aside) and then, when those pass, runs **your** PHPStan over the compiled output +and maps the findings back to the `.xphp` template — one config, one gate (pass +`--no-phpstan` to skip it). You still run `compile` to emit the PHP. See [Errors and diagnostics](docs/errors.md#xphp-check--validate-without-emitting). ## See also diff --git a/docs/errors.md b/docs/errors.md index 6cd1122..0b91d42 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -45,6 +45,9 @@ The `json` and `github` formats tag each diagnostic with a stable code: | `xphp.closure_this_capture` | a generic closure/arrow used via turbofish captures `$this` (unsupported) | | `xphp.static_closure` | a generic `static` closure used via turbofish (unsupported) | | `xphp.parse_error` | the file isn't valid PHP after the generic strip pass | +| `phpstan.*` | a PHPStan finding in the compiled output, mapped back to the template declaration (the code is `phpstan.` + PHPStan's own identifier, e.g. `phpstan.return.type`) — present only when the PHPStan pass runs | +| `phpstan.unavailable` | (Warning) no phpstan binary was found, so the PHPStan pass was skipped | +| `phpstan.run_failed` | (Warning) phpstan was found but couldn't complete (e.g. a config error) | > **Scope.** `xphp check` runs every generic *validation* check `xphp compile` > does — class/interface/trait-level **and** method/function/closure-level @@ -55,6 +58,39 @@ The `json` and `github` formats tag each diagnostic with a stable code: > errors). You still run `xphp compile` to produce the PHP; `check` is the fast > validation gate in front of it. +### PHPStan over the compiled output + +PHPStan never sees `.xphp` generic sugar, so it can't analyse a generic body. When +the generic checks above pass, `xphp check` closes that gap: it compiles your +sources to a throwaway directory, runs **your** PHPStan over the concrete +(monomorphized) output, and maps any finding back to the originating `.xphp` +template declaration — the diagnostic names the concrete instantiation that +surfaced it (e.g. *triggered by `App\Box`*). The findings merge into the same +report and exit code as the generic checks: **one PHPStan, one config, one gate.** + +- **One config.** Your own config drives the level, rules, and extensions — + auto-detected at the project root (`phpstan.neon`, then `phpstan.neon.dist`, + then `phpstan.dist.neon`), or pass `--phpstan-config=PATH`. xphp adds only what's + needed to resolve the generated code's symbols; it picks no level of its own. +- **Opt-out + graceful.** `phpstan/phpstan` is an optional, `require-dev`-style + dependency and is never bundled in the PHAR. The binary is resolved as + `--phpstan-bin=PATH` → `vendor/bin/phpstan` → `$PATH`; if none is found (or an + explicit `--phpstan-bin` doesn't exist), or phpstan can't complete, `check` + emits a **Warning** and carries on — a missing optional tool never fails the + gate. Pass `--no-phpstan` to skip the pass entirely. +- **One representative per template.** A body type error is identical across every + specialization of a template, so xphp analyses a single representative + specialization per template — surfacing the bug once, not once per instantiation. + (Trade-off: a body error that only manifests for *specific* concrete arguments may + be missed; that's the value-flow class PHPStan can't attribute to a template line + anyway.) + +```bash +vendor/bin/xphp check src # generic checks + PHPStan, one gate +vendor/bin/xphp check src --no-phpstan # generic checks only +vendor/bin/xphp check src --phpstan-config=phpstan.neon.dist +``` + In CI (GitHub Actions), one step gates the build and annotates the diff: ```yaml diff --git a/test/smoke/check.sh b/test/smoke/check.sh index f2fe831..5ed6d36 100755 --- a/test/smoke/check.sh +++ b/test/smoke/check.sh @@ -19,7 +19,10 @@ FIX="test/fixture/check" # Captures stdout+stderr into the global OUT; fails loudly on a code mismatch. check_exit() { set +e - OUT=$($XPHP check "$2" --format="$3" 2>&1) + # --no-phpstan keeps this a deterministic check of the binary's exit/render + # contract: it must not depend on a phpstan install (the PHAR bundles none) or + # on a consumer config. The PHPStan pass is covered by `make test/phpstan`. + OUT=$($XPHP check "$2" --format="$3" --no-phpstan 2>&1) code=$? set -e if [ "$code" != "$1" ]; then From ed4b1c77817af3f2ee8fd878b22da844e9cd07bd Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 18 Jun 2026 16:09:41 +0000 Subject: [PATCH 022/114] feat(check): machinery to detect undeclared type parameters (no-op) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundations for catching a stray/undeclared type parameter in a generic member — e.g. `interface Foo { 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) --- .../Monomorphize/NamespaceContext.php | 11 ++ src/Transpiler/Monomorphize/TypeHierarchy.php | 14 ++ .../Monomorphize/XphpSourceParser.php | 44 +++++- .../Monomorphize/NamespaceContextTest.php | 31 ++++ .../SuspectUndeclaredTypeTagTest.php | 144 ++++++++++++++++++ .../Monomorphize/TypeHierarchyTest.php | 25 +++ 6 files changed, 265 insertions(+), 4 deletions(-) create mode 100644 test/Transpiler/Monomorphize/SuspectUndeclaredTypeTagTest.php diff --git a/src/Transpiler/Monomorphize/NamespaceContext.php b/src/Transpiler/Monomorphize/NamespaceContext.php index 7a4932c..3c09f3c 100644 --- a/src/Transpiler/Monomorphize/NamespaceContext.php +++ b/src/Transpiler/Monomorphize/NamespaceContext.php @@ -84,6 +84,17 @@ public function currentNamespace(): string return $this->currentNamespace; } + /** + * Whether a bare name's first segment is brought into scope by a `use` + * import. Used to spare imported (and therefore deliberate) class references + * from the undeclared-type-parameter check: an imported name is the author's + * explicit statement that the type lives elsewhere, so it's never "suspect". + */ + public function isImported(string $name): bool + { + return isset($this->useMap[self::firstSegment($name)]); + } + private static function firstSegment(string $name): string { $pos = strpos($name, '\\'); diff --git a/src/Transpiler/Monomorphize/TypeHierarchy.php b/src/Transpiler/Monomorphize/TypeHierarchy.php index 8c388e4..d5bb52d 100644 --- a/src/Transpiler/Monomorphize/TypeHierarchy.php +++ b/src/Transpiler/Monomorphize/TypeHierarchy.php @@ -124,6 +124,20 @@ public function isSubtype(string $concrete, string $bound): ?bool return false; } + /** + * Whether $fqn names a type the source set knows about: a class/interface/trait + * declared in a scanned `.xphp` file (every such ClassLike is a key in the + * ancestor map) or a built-in PHP interface/class. Used by the + * undeclared-type-parameter check to tell a real (in-project or built-in) type + * from a name that resolves to nothing. + */ + public function isDeclared(string $fqn): bool + { + $fqn = ltrim($fqn, '\\'); + + return isset($this->ancestors[$fqn]) || in_array($fqn, self::BUILTIN_TYPES, true); + } + /** * @param list $ast * @param array> $ancestors out-param accumulator diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 175a463..1fba5da 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -76,6 +76,14 @@ final class XphpSourceParser public const ATTR_METHOD_GENERIC_PARAMS = 'xphp:methodGenericParams'; public const ATTR_METHOD_GENERIC_ARGS = 'xphp:methodGenericArgs'; + // A bare, single-segment, non-imported class-name used inside a generic context + // (a template or generic method/function/closure) that is NOT a declared type + // parameter. Carries the resolved FQN. The undeclared-type-parameter validator + // flags it when the FQN resolves to no declared type — catching `Foo` whose + // member uses an undeclared `T`. Imported / fully-qualified names are never + // tagged (the escape hatch). Advisory metadata only — not emitted. + public const ATTR_SUSPECT_UNDECLARED_TYPE = 'xphp:suspectUndeclaredType'; + public const SCALAR_TYPES = [ 'int', 'integer', 'string', 'bool', 'boolean', 'float', 'double', 'void', 'mixed', 'never', 'null', 'false', 'true', @@ -1653,10 +1661,22 @@ private function markName(Name $node): void if (!$this->shouldQualify($node)) { return; } - $node->setAttribute( - XphpSourceParser::ATTR_RESOLVED_FQN, - $this->ctx->resolveAgainstContext($node->toString()), - ); + $name = $node->toString(); + $resolved = $this->ctx->resolveAgainstContext($name); + $node->setAttribute(XphpSourceParser::ATTR_RESOLVED_FQN, $resolved); + + // Flag a bare, single-segment, non-imported class reference used inside a + // generic context. shouldQualify() already excluded declared type-params, + // scalars, FQ names, and generic-arg-bearing names, so what's left is either + // a real (in-project / built-in) type or a stray/undeclared type parameter + // like the `T` in `interface Foo { add(T $x): void; }`. The validator + // resolves which using the declared-set; here we only record the suspicion. + if (count($node->getParts()) === 1 + && $this->hasEnclosingTypeParams() + && !$this->ctx->isImported($name) + ) { + $node->setAttribute(XphpSourceParser::ATTR_SUSPECT_UNDECLARED_TYPE, $resolved); + } } /** @@ -1826,6 +1846,22 @@ private function isEnclosingTypeParam(string $name): bool } return false; } + + /** + * True when some enclosing scope declares type parameters — i.e. we're + * inside a generic template or a generic method/function/closure. Every + * class/method pushes a frame (empty for non-generic ones), so this asks + * whether any frame is non-empty rather than whether the stack is non-empty. + */ + private function hasEnclosingTypeParams(): bool + { + foreach ($this->typeParamStack as $scope) { + if ($scope !== []) { + return true; + } + } + return false; + } }); $traverser->traverse($ast); diff --git a/test/Transpiler/Monomorphize/NamespaceContextTest.php b/test/Transpiler/Monomorphize/NamespaceContextTest.php index 48f3152..2890826 100644 --- a/test/Transpiler/Monomorphize/NamespaceContextTest.php +++ b/test/Transpiler/Monomorphize/NamespaceContextTest.php @@ -97,6 +97,37 @@ public function testEnterNamespaceResetsTheUseMap(): void self::assertSame('App\\Second\\Hello', $ctx->resolveAgainstContext('Hello')); } + public function testIsImportedTrueForAliasedFirstSegment(): void + { + $ctx = new NamespaceContext(); + $ctx->enterNamespace('App'); + $ctx->indexUse(self::makeUse('Other\\Vendor\\Box')); + + self::assertTrue($ctx->isImported('Box')); + self::assertTrue($ctx->isImported('Box\\Sub')); // first segment is what matters + } + + public function testIsImportedFalseForNonImportedName(): void + { + $ctx = new NamespaceContext(); + $ctx->enterNamespace('App'); + $ctx->indexUse(self::makeUse('Other\\Vendor\\Box')); + + self::assertFalse($ctx->isImported('T')); + self::assertFalse($ctx->isImported('Unrelated')); + } + + public function testIsImportedResetsWithNamespace(): void + { + $ctx = new NamespaceContext(); + $ctx->enterNamespace('App\\First'); + $ctx->indexUse(self::makeUse('First\\Box')); + self::assertTrue($ctx->isImported('Box')); + + $ctx->enterNamespace('App\\Second'); + self::assertFalse($ctx->isImported('Box')); + } + private static function makeUse(string $fqn, ?string $alias = null): Use_ { $useItem = new UseItem( diff --git a/test/Transpiler/Monomorphize/SuspectUndeclaredTypeTagTest.php b/test/Transpiler/Monomorphize/SuspectUndeclaredTypeTagTest.php new file mode 100644 index 0000000..004eecf --- /dev/null +++ b/test/Transpiler/Monomorphize/SuspectUndeclaredTypeTagTest.php @@ -0,0 +1,144 @@ +taggedNames(<<<'PHP' + + { + public function add(T $x): void; + public function get(): Z; + } + PHP); + + // `T` (undeclared) is tagged with its namespace-resolved FQN; `Z` is a + // declared param (never qualified, never tagged). + self::assertSame(['T' => 'App\\T'], $tagged); + } + + public function testStrayNamesInPropertyAndReturnPositionsAreTagged(): void + { + $tagged = $this->taggedNames(<<<'PHP' + + { + public Stray $item; + public function get(): Other {} + } + PHP); + + // Locks the contract that WI-02 relies on: property + return-type positions + // (not just params) are tagged. + self::assertSame(['Stray' => 'App\\Stray', 'Other' => 'App\\Other'], $tagged); + } + + public function testImportedNameIsNotTagged(): void + { + $tagged = $this->taggedNames(<<<'PHP' + + { + public function set(Real $x): void {} + } + PHP); + + self::assertSame([], $tagged); + } + + public function testFullyQualifiedAndScalarAreNotTagged(): void + { + $tagged = $this->taggedNames(<<<'PHP' + + { + public function a(\App\Thing $x): void {} + public function b(int $x): int { return $x; } + } + PHP); + + self::assertSame([], $tagged); + } + + public function testBareNameInNonGenericClassIsNotTagged(): void + { + $tagged = $this->taggedNames(<<<'PHP' + taggedNames(<<<'PHP' + (B $x): void {} + } + PHP); + + // The method declares , so we're in a generic context; the stray `B` is tagged. + self::assertSame(['B' => 'App\\B'], $tagged); + } + + /** + * @return array tagged short-name => resolved FQN + */ + private function taggedNames(string $source): array + { + $ast = (new XphpSourceParser((new ParserFactory())->createForHostVersion()))->parse($source); + + $visitor = new class extends NodeVisitorAbstract { + /** @var array */ + public array $tagged = []; + + public function enterNode(Node $node): null + { + if ($node instanceof Name) { + $fqn = $node->getAttribute(XphpSourceParser::ATTR_SUSPECT_UNDECLARED_TYPE); + if (is_string($fqn)) { + $this->tagged[$node->toString()] = $fqn; + } + } + return null; + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + + return $visitor->tagged; + } +} diff --git a/test/Transpiler/Monomorphize/TypeHierarchyTest.php b/test/Transpiler/Monomorphize/TypeHierarchyTest.php index 8d62efe..8ec7359 100644 --- a/test/Transpiler/Monomorphize/TypeHierarchyTest.php +++ b/test/Transpiler/Monomorphize/TypeHierarchyTest.php @@ -192,4 +192,29 @@ class Tag implements Stringable self::assertTrue($hierarchy->isSubtype('App\\Tag', 'Stringable')); } + + public function testIsDeclaredForClassLikeInTheSourceSet(): void + { + $hierarchy = new TypeHierarchy(['App\\Foo' => [], 'App\\Bar' => ['App\\Foo']]); + + self::assertTrue($hierarchy->isDeclared('App\\Foo')); + self::assertTrue($hierarchy->isDeclared('App\\Bar')); + self::assertTrue($hierarchy->isDeclared('\\App\\Foo')); // leading backslash normalised + } + + public function testIsDeclaredForBuiltinType(): void + { + $hierarchy = new TypeHierarchy([]); + + self::assertTrue($hierarchy->isDeclared('Stringable')); + self::assertTrue($hierarchy->isDeclared('Countable')); + } + + public function testIsNotDeclaredForUnknownName(): void + { + $hierarchy = new TypeHierarchy(['App\\Foo' => []]); + + self::assertFalse($hierarchy->isDeclared('App\\T')); + self::assertFalse($hierarchy->isDeclared('App\\Nonexistent')); + } } From 1bba5b5c0b1b31634d1b28613ea876e43aeb327a Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 18 Jun 2026 16:34:16 +0000 Subject: [PATCH 023/114] feat(check): flag undeclared type parameters in generic members MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catches a stray/typo'd type parameter in a generic class/interface/trait member — e.g. `interface Foo { 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) --- docs/errors.md | 1 + src/Transpiler/Monomorphize/Compiler.php | 5 + src/Transpiler/Monomorphize/Registry.php | 26 +++ .../UndeclaredTypeParameterValidator.php | 181 ++++++++++++++++++ .../Monomorphize/CheckPassIntegrationTest.php | 56 ++++++ .../undeclared_type_param/source/Box.xphp | 9 + .../source/CollectionInterface.xphp | 20 ++ .../source/Foo.xphp | 20 ++ .../source/Foo.xphp | 37 ++++ 9 files changed, 355 insertions(+) create mode 100644 src/Transpiler/Monomorphize/UndeclaredTypeParameterValidator.php create mode 100644 test/fixture/check/undeclared_type_param/source/Box.xphp create mode 100644 test/fixture/check/undeclared_type_param/source/CollectionInterface.xphp create mode 100644 test/fixture/check/undeclared_type_param_escape/source/Foo.xphp create mode 100644 test/fixture/check/undeclared_type_param_positions/source/Foo.xphp diff --git a/docs/errors.md b/docs/errors.md index 0b91d42..66abbb5 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -41,6 +41,7 @@ The `json` and `github` formats tag each diagnostic with a stable code: | `xphp.variance_position` | a `+T`/`-T` parameter appears in a position its variance forbids | | `xphp.inner_variance` | variance is violated through another generic's slot (composition) | | `xphp.undefined_template` | a generic was instantiated but never declared | +| `xphp.undeclared_type` | a generic member names a type that is neither a declared type parameter nor a known type — e.g. `interface Foo { add(T $x); }` where `T` is a stray/typo'd parameter (would otherwise compile to a reference to a non-existent class). Imported (`use`) and fully-qualified names are never flagged. A real class referenced bare must be imported or fully-qualified — including a class in the *same namespace* that lives in a plain `.php` file (which `xphp check` doesn't scan), even though PHP itself wouldn't require the `use` | | `xphp.duplicate_generic_function` | the same generic function is declared in two files | | `xphp.closure_this_capture` | a generic closure/arrow used via turbofish captures `$this` (unsupported) | | `xphp.static_closure` | a generic `static` closure used via turbofish (unsupported) | diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index d3d1005..e80497e 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -95,6 +95,10 @@ public function compile( // Runs BEFORE the defaults-vs-bounds check so that, when a class has both, // the variance error surfaces first (the order it surfaced at parse time). $variancePositionFlagged = $registry->validateVariancePositions(); + // Undeclared type names in member signatures (a stray/typo'd type param) + // fail before defaults/instantiation so a non-existent reference never + // reaches emission as broken PHP. + $registry->validateUndeclaredTypeParameters(); $registry->validateDefaultsAgainstBounds(); // Inner-template variance composition: every template's variance // markers are known by now, so cases the parse-time validator @@ -275,6 +279,7 @@ public function check(FilepathArray $sources): DiagnosticCollector $collector->collectDefinitions($ast, $filepath); } $variancePositionFlagged = $registry->validateVariancePositions(); + $registry->validateUndeclaredTypeParameters(); $registry->validateDefaultsAgainstBounds(); $registry->validateInnerVariance($variancePositionFlagged); foreach ($astPerFile as $filepath => $ast) { diff --git a/src/Transpiler/Monomorphize/Registry.php b/src/Transpiler/Monomorphize/Registry.php index 6db6e53..80f0cd3 100644 --- a/src/Transpiler/Monomorphize/Registry.php +++ b/src/Transpiler/Monomorphize/Registry.php @@ -402,6 +402,32 @@ public function validateVariancePositions(): array return $flagged; } + /** + * Undeclared-type check over every collected definition (delegates to + * {@see UndeclaredTypeParameterValidator}): a generic member naming a type + * that is neither a declared type parameter nor a known type — e.g. the `T` + * in `interface Foo { add(T $x): void; }`. With this Registry's collector + * it gathers every finding (each at the offending member); without one + * (compile) it throws the first. Skipped when no hierarchy was attached + * (bare-Registry tests have nothing to resolve names against). + */ + public function validateUndeclaredTypeParameters(): void + { + if ($this->hierarchy === null) { + return; + } + + foreach ($this->definitions as $templateFqn => $definition) { + UndeclaredTypeParameterValidator::assert( + $definition->templateAst, + $templateFqn, + $this->hierarchy, + $this->diagnostics, + $definition->sourceFile, + ); + } + } + /** * Inner-template variance composition check over every collected definition (delegates to * {@see InnerVarianceValidator}). With this Registry's collector it gathers every violation diff --git a/src/Transpiler/Monomorphize/UndeclaredTypeParameterValidator.php b/src/Transpiler/Monomorphize/UndeclaredTypeParameterValidator.php new file mode 100644 index 0000000..ce0416c --- /dev/null +++ b/src/Transpiler/Monomorphize/UndeclaredTypeParameterValidator.php @@ -0,0 +1,181 @@ + { public function add(T $x): void; }`, which would otherwise + * be silently emitted as a reference to a non-existent class `\App\Foo\T`. + * + * Works off the parser's {@see XphpSourceParser::ATTR_SUSPECT_UNDECLARED_TYPE} + * tag: the parser stamps it on a bare, single-segment, non-imported class name + * used inside a generic context after excluding declared params, scalars, + * fully-qualified names, and generic-arg-bearing names. So a tagged name is + * exactly "a real type or a stray type parameter"; a finding is a tagged name + * whose resolved FQN is not {@see TypeHierarchy::isDeclared}. + * + * Walks the same signature positions as {@see VariancePositionValidator} + * (properties, method/constructor params, returns, and nested closure/arrow + * signatures) — NOT `extends`/`implements`/`new`/`catch` or method bodies. + * Bounds and defaults are checked separately (they're TypeRef trees, not AST + * type nodes). Runs over collected definitions; with a `DiagnosticCollector` it + * gathers every finding (each at the offending member line) and continues, + * without one it throws the first (shared message builder). + */ +final class UndeclaredTypeParameterValidator +{ + /** Stable diagnostic code for an undeclared type used in a generic member. */ + public const CODE_UNDECLARED_TYPE = 'xphp.undeclared_type'; + + /** @var list */ + private array $violations = []; + + private function __construct( + private readonly TypeHierarchy $hierarchy, + private readonly string $templateFqn, + ) { + } + + public static function assert( + ClassLike $node, + string $templateFqn, + TypeHierarchy $hierarchy, + ?DiagnosticCollector $diagnostics = null, + ?string $file = null, + ): void { + $validator = new self($hierarchy, $templateFqn); + $validator->collect($node); + if ($validator->violations === []) { + return; + } + + if ($diagnostics === null) { + // Compile-mode: fail fast on the first finding rather than emit broken PHP. + throw new RuntimeException($validator->violations[0]['message']); + } + + foreach ($validator->violations as $violation) { + $location = $file !== null ? new SourceLocation($file, $violation['line']) : null; + $diagnostics->add(new Diagnostic( + Severity::Error, + self::CODE_UNDECLARED_TYPE, + $violation['message'], + $location, + )); + } + } + + private static function undeclaredTypeMessage(string $name, string $templateFqn): string + { + return sprintf( + 'Type `%s` used in `%s` is not a declared type parameter and does not resolve to a known class, interface, or trait. Declare it as a type parameter, or import (`use`) / fully-qualify it if it names a real type.', + $name, + $templateFqn, + ); + } + + private function collect(ClassLike $node): void + { + // Walks every member of this generic template, including the signatures of + // any generic methods nested in it (so a stray name in `Box{ map(U) }` + // is caught here). Method/function/closure generics declared OUTSIDE a generic + // template (e.g. on a plain class, or a free function) are validated separately + // so the two passes never report the same node twice. + foreach ($node->getProperties() as $property) { + if ($property->type !== null) { + $this->checkType($property->type); + } + } + foreach ($node->getMethods() as $method) { + $this->checkMethod($method); + } + } + + private function checkMethod(ClassMethod $method): void + { + foreach ($method->params as $param) { + // @phpstan-ignore-next-line instanceof.alwaysTrue — defensive guard against nikic/php-parser PHPDoc-narrowed param collection element. + if ($param instanceof Param && $param->type !== null) { + $this->checkType($param->type); + } + } + if ($method->returnType !== null) { + $this->checkType($method->returnType); + } + if ($method->stmts !== null) { + $this->walkBodyForNestedClosures($method->stmts); + } + } + + /** + * Walk a body looking for closure / arrow SIGNATURES (params + return types) — + * a generic context's stray type can appear there too. Mirrors + * {@see VariancePositionValidator::walkBodyForNestedClosures}; bodies' other + * expressions (`new X`, etc.) are intentionally not checked here. + */ + private function walkBodyForNestedClosures(mixed $node): void + { + if ($node instanceof Closure || $node instanceof ArrowFunction) { + foreach ($node->params as $param) { + // @phpstan-ignore-next-line instanceof.alwaysTrue — defensive guard against nikic/php-parser PHPDoc-narrowed param collection element. + if ($param instanceof Param && $param->type !== null) { + $this->checkType($param->type); + } + } + if ($node->returnType !== null) { + $this->checkType($node->returnType); + } + // Don't stop — a closure body may contain further closures. + } + + if (is_array($node)) { + foreach ($node as $child) { + $this->walkBodyForNestedClosures($child); + } + } elseif ($node instanceof Node) { + foreach ($node->getSubNodeNames() as $subName) { + $this->walkBodyForNestedClosures($node->$subName); + } + } + } + + private function checkType(Node $type): void + { + if ($type instanceof Name) { + $fqn = $type->getAttribute(XphpSourceParser::ATTR_SUSPECT_UNDECLARED_TYPE); + if (is_string($fqn) && !$this->hierarchy->isDeclared($fqn)) { + $this->violations[] = [ + 'message' => self::undeclaredTypeMessage($type->toString(), $this->templateFqn), + 'line' => $type->getStartLine(), + ]; + } + } elseif ($type instanceof NullableType) { + $this->checkType($type->type); + } elseif ($type instanceof UnionType || $type instanceof IntersectionType) { + foreach ($type->types as $inner) { + $this->checkType($inner); + } + } + // Identifier (scalar / pseudo-type) and any other ComplexType: nothing to check. + } +} diff --git a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php index 8822852..22321f9 100644 --- a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php +++ b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php @@ -191,6 +191,62 @@ public function testCompileStillThrowsOnGenericFunctionBound(): void $this->compileFixture('generic_function_bound'); } + public function testUndeclaredTypeParametersAreCollected(): void + { + // Two stray type names (`T`, `U`) in one template → both reported in one run; + // the declared `Z`, the scalar `int`, and the in-source class `Box` are clean. + $diagnostics = $this->check('undeclared_type_param'); + + self::assertCount(2, $diagnostics->all()); + $byName = []; + foreach ($diagnostics->all() as $d) { + self::assertSame(UndeclaredTypeParameterValidator::CODE_UNDECLARED_TYPE, $d->code); + self::assertNotNull($d->location); + self::assertStringEndsWith('CollectionInterface.xphp', $d->location->file); + preg_match('/Type `(\w+)`/', $d->message, $m); + $byName[$m[1]] = $d->location->line; + } + self::assertSame(['T', 'U'], array_keys($byName)); + self::assertSame(11, $byName['T']); // `add(T $element)` line + self::assertSame(13, $byName['U']); // `wrap(U $value)` line + } + + public function testUndeclaredTypesAreCaughtInEveryMemberPosition(): void + { + // property, constructor-promoted param, return, nullable param, union return, + // intersection param, and a nested closure signature — each stray name is flagged. + $diagnostics = $this->check('undeclared_type_param_positions'); + + $names = []; + foreach ($diagnostics->all() as $d) { + self::assertSame(UndeclaredTypeParameterValidator::CODE_UNDECLARED_TYPE, $d->code); + preg_match('/Type `(\w+)`/', $d->message, $m); + $names[$m[1]] = true; + } + ksort($names); + self::assertSame( + ['Clo', 'CloRet', 'InA', 'InB', 'Nul', 'Promo', 'Prop', 'Ret', 'UnA', 'UnB'], + array_keys($names), + ); + } + + public function testImportedAndFullyQualifiedTypesAreNotFlaggedAsUndeclared(): void + { + $diagnostics = $this->check('undeclared_type_param_escape'); + + self::assertFalse($diagnostics->hasErrors()); + self::assertSame([], $diagnostics->all()); + } + + public function testCompileStillThrowsOnUndeclaredTypeParameter(): void + { + // Throws the FIRST finding (the `T` in add(), before `U` in wrap()), not a + // silent broken emit. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Type `T` used in `App\\Undeclared\\CollectionInterface`'); + $this->compileFixture('undeclared_type_param'); + } + public function testCompileStillThrowsOnGenericMethodMissingArgument(): void { $this->expectException(RuntimeException::class); diff --git a/test/fixture/check/undeclared_type_param/source/Box.xphp b/test/fixture/check/undeclared_type_param/source/Box.xphp new file mode 100644 index 0000000..853ccfc --- /dev/null +++ b/test/fixture/check/undeclared_type_param/source/Box.xphp @@ -0,0 +1,9 @@ + +{ + // `T` and `U` are not declared type parameters (only `Z` is) and name no real + // type — each is a stray/typo'd parameter. Both are reported in one run. + public function add(T $element): void; + + public function wrap(U $value): void; + + // Clean: `Z` is the declared parameter, `int` is a scalar, and `Box` is a + // class declared in this source set — none are flagged. + public function get(int $index): Z; + + public function store(Box $box): void; +} diff --git a/test/fixture/check/undeclared_type_param_escape/source/Foo.xphp b/test/fixture/check/undeclared_type_param_escape/source/Foo.xphp new file mode 100644 index 0000000..d859e5d --- /dev/null +++ b/test/fixture/check/undeclared_type_param_escape/source/Foo.xphp @@ -0,0 +1,20 @@ + +{ + // The escape hatches: an imported name and a fully-qualified name are + // deliberate references to types elsewhere, so neither is flagged even though + // neither is declared in this source set. + public function a(Real $x): void; + + public function b(\App\Models\Other $y): void; + + // Scalar + declared parameter: also clean. + public function c(int $n): Z; +} diff --git a/test/fixture/check/undeclared_type_param_positions/source/Foo.xphp b/test/fixture/check/undeclared_type_param_positions/source/Foo.xphp new file mode 100644 index 0000000..fb2cc9b --- /dev/null +++ b/test/fixture/check/undeclared_type_param_positions/source/Foo.xphp @@ -0,0 +1,37 @@ + +{ + public Prop $field; + + public function __construct(public Promo $promoted) + { + } + + public function ret(): Ret + { + } + + public function nullable(?Nul $x, $untyped): void + { + } + + public function union(): UnA|UnB + { + } + + public function intersection(InA&InB $x): void + { + } + + public function closure(): void + { + // Stray names in a nested closure signature (param + return) are caught too; + // the untyped param exercises the "param has no type" branch. + $f = function (Clo $x, $untyped): CloRet {}; + } +} From 1a9fc6d481a6393b66873547a7c3ddfe1073ff0e Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 18 Jun 2026 16:50:06 +0000 Subject: [PATCH 024/114] feat(check): extend undeclared-type detection to method-level generics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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(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) --- src/Transpiler/Monomorphize/Compiler.php | 6 + .../UndeclaredTypeParameterValidator.php | 167 +++++++++++++++--- .../Monomorphize/CheckPassIntegrationTest.php | 42 ++++- .../source/Util.xphp | 28 +++ .../source/Box.xphp | 14 ++ 5 files changed, 231 insertions(+), 26 deletions(-) create mode 100644 test/fixture/check/undeclared_type_param_method/source/Util.xphp create mode 100644 test/fixture/check/undeclared_type_param_nested_method/source/Box.xphp diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index e80497e..6b37851 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -73,6 +73,11 @@ public function compile( // concrete `Box` reference gets collected and specialized through the usual // class-level path. The hierarchy is passed through so method/function-level // `T: Bound` is validated at compile time too (same shape as the class-level path). + // Reject a stray/undeclared type parameter in a generic method/function/closure + // signature BEFORE specialization runs (process() strips the templates from the + // AST in compile-mode, so this must precede it). + UndeclaredTypeParameterValidator::assertMethodLevel($astPerFile, $hierarchy); + $methodCompiler = new GenericMethodCompiler($this->hashLength, $hierarchy); $methodCompiler->process($astPerFile); @@ -280,6 +285,7 @@ public function check(FilepathArray $sources): DiagnosticCollector } $variancePositionFlagged = $registry->validateVariancePositions(); $registry->validateUndeclaredTypeParameters(); + UndeclaredTypeParameterValidator::assertMethodLevel($astPerFile, $hierarchy, $diagnostics); $registry->validateDefaultsAgainstBounds(); $registry->validateInnerVariance($variancePositionFlagged); foreach ($astPerFile as $filepath => $ast) { diff --git a/src/Transpiler/Monomorphize/UndeclaredTypeParameterValidator.php b/src/Transpiler/Monomorphize/UndeclaredTypeParameterValidator.php index ce0416c..b143926 100644 --- a/src/Transpiler/Monomorphize/UndeclaredTypeParameterValidator.php +++ b/src/Transpiler/Monomorphize/UndeclaredTypeParameterValidator.php @@ -7,13 +7,16 @@ use PhpParser\Node; use PhpParser\Node\Expr\ArrowFunction; use PhpParser\Node\Expr\Closure; +use PhpParser\Node\FunctionLike; use PhpParser\Node\IntersectionType; use PhpParser\Node\Name; use PhpParser\Node\NullableType; -use PhpParser\Node\Param; use PhpParser\Node\Stmt\ClassLike; use PhpParser\Node\Stmt\ClassMethod; +use PhpParser\Node\Stmt\Function_; use PhpParser\Node\UnionType; +use PhpParser\NodeTraverser; +use PhpParser\NodeVisitorAbstract; use RuntimeException; use XPHP\Diagnostics\Diagnostic; use XPHP\Diagnostics\DiagnosticCollector; @@ -52,10 +55,13 @@ final class UndeclaredTypeParameterValidator private function __construct( private readonly TypeHierarchy $hierarchy, - private readonly string $templateFqn, + private readonly string $context, ) { } + /** + * Validate a generic class/interface/trait template's member signatures. + */ public static function assert( ClassLike $node, string $templateFqn, @@ -63,18 +69,66 @@ public static function assert( ?DiagnosticCollector $diagnostics = null, ?string $file = null, ): void { - $validator = new self($hierarchy, $templateFqn); + $validator = new self($hierarchy, 'template `' . $templateFqn . '`'); $validator->collect($node); - if ($validator->violations === []) { + self::report($validator->violations, $diagnostics, $file); + } + + /** + * Validate generic method/function/closure signatures declared OUTSIDE a generic + * template (a generic method on a plain class, a free generic function, a generic + * closure/arrow). Those nested inside a generic template are owned by {@see assert} + * (via its member walk), so they're skipped here to avoid reporting the same node + * twice. + * + * @param array> $astPerFile keyed by filepath + */ + public static function assertMethodLevel( + array $astPerFile, + TypeHierarchy $hierarchy, + ?DiagnosticCollector $diagnostics = null, + ): void { + /** @var list $findings */ + $findings = []; + foreach ($astPerFile as $file => $ast) { + foreach (self::findMethodLevelGenerics($ast) as $generic) { + $validator = new self($hierarchy, $generic['context']); + $validator->checkCallable($generic['node']); + foreach ($validator->violations as $violation) { + $findings[] = $violation + ['file' => $file]; + } + } + } + + if ($findings === []) { return; } + if ($diagnostics === null) { + throw new RuntimeException($findings[0]['message']); + } + foreach ($findings as $finding) { + $diagnostics->add(new Diagnostic( + Severity::Error, + self::CODE_UNDECLARED_TYPE, + $finding['message'], + new SourceLocation($finding['file'], $finding['line']), + )); + } + } + /** + * @param list $violations + */ + private static function report(array $violations, ?DiagnosticCollector $diagnostics, ?string $file): void + { + if ($violations === []) { + return; + } if ($diagnostics === null) { // Compile-mode: fail fast on the first finding rather than emit broken PHP. - throw new RuntimeException($validator->violations[0]['message']); + throw new RuntimeException($violations[0]['message']); } - - foreach ($validator->violations as $violation) { + foreach ($violations as $violation) { $location = $file !== null ? new SourceLocation($file, $violation['line']) : null; $diagnostics->add(new Diagnostic( Severity::Error, @@ -85,12 +139,70 @@ public static function assert( } } - private static function undeclaredTypeMessage(string $name, string $templateFqn): string + /** + * Find every generic method/function/closure NOT enclosed by a generic template. + * + * @param list $ast + * @return list + */ + private static function findMethodLevelGenerics(array $ast): array + { + $visitor = new class extends NodeVisitorAbstract { + public int $genericClassDepth = 0; + /** @var list */ + public array $found = []; + + public function enterNode(Node $node): null + { + // Skip generics nested in a generic template — its member walk owns them. + // Known limitation: a generic method on an ANONYMOUS class buried in a + // generic class's method body is owned by neither pass (the member walk + // doesn't recurse into anon classes, and depth>0 skips it here). That shape + // can't be specialized downstream anyway, so it's a latent edge, not a regression. + if ($node instanceof ClassLike && $node->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS) !== null) { + $this->genericClassDepth++; + } + if ($this->genericClassDepth === 0 + && $node instanceof FunctionLike + && $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS) !== null + ) { + $this->found[] = ['node' => $node, 'context' => self::contextLabel($node)]; + } + return null; + } + + public function leaveNode(Node $node): null + { + if ($node instanceof ClassLike && $node->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS) !== null) { + $this->genericClassDepth--; + } + return null; + } + + private static function contextLabel(FunctionLike $node): string + { + return match (true) { + $node instanceof ClassMethod => 'method `' . $node->name->toString() . '`', + $node instanceof Function_ => 'function `' . $node->name->toString() . '`', + $node instanceof ArrowFunction => 'arrow function', + default => 'closure', + }; + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + + return $visitor->found; + } + + private static function undeclaredTypeMessage(string $name, string $context): string { return sprintf( - 'Type `%s` used in `%s` is not a declared type parameter and does not resolve to a known class, interface, or trait. Declare it as a type parameter, or import (`use`) / fully-qualify it if it names a real type.', + 'Type `%s` used in %s is not a declared type parameter and does not resolve to a known class, interface, or trait. Declare it as a type parameter, or import (`use`) / fully-qualify it if it names a real type.', $name, - $templateFqn, + $context, ); } @@ -107,23 +219,28 @@ private function collect(ClassLike $node): void } } foreach ($node->getMethods() as $method) { - $this->checkMethod($method); + $this->checkCallable($method); } } - private function checkMethod(ClassMethod $method): void + /** + * Check a callable's signature (params + return) and any closures nested in its + * body. Shared by the class-member walk and the standalone method-level pass. + */ + private function checkCallable(FunctionLike $callable): void { - foreach ($method->params as $param) { - // @phpstan-ignore-next-line instanceof.alwaysTrue — defensive guard against nikic/php-parser PHPDoc-narrowed param collection element. - if ($param instanceof Param && $param->type !== null) { + foreach ($callable->getParams() as $param) { + if ($param->type !== null) { $this->checkType($param->type); } } - if ($method->returnType !== null) { - $this->checkType($method->returnType); + $returnType = $callable->getReturnType(); + if ($returnType !== null) { + $this->checkType($returnType); } - if ($method->stmts !== null) { - $this->walkBodyForNestedClosures($method->stmts); + $stmts = $callable->getStmts(); + if ($stmts !== null) { + $this->walkBodyForNestedClosures($stmts); } } @@ -136,14 +253,14 @@ private function checkMethod(ClassMethod $method): void private function walkBodyForNestedClosures(mixed $node): void { if ($node instanceof Closure || $node instanceof ArrowFunction) { - foreach ($node->params as $param) { - // @phpstan-ignore-next-line instanceof.alwaysTrue — defensive guard against nikic/php-parser PHPDoc-narrowed param collection element. - if ($param instanceof Param && $param->type !== null) { + foreach ($node->getParams() as $param) { + if ($param->type !== null) { $this->checkType($param->type); } } - if ($node->returnType !== null) { - $this->checkType($node->returnType); + $returnType = $node->getReturnType(); + if ($returnType !== null) { + $this->checkType($returnType); } // Don't stop — a closure body may contain further closures. } @@ -165,7 +282,7 @@ private function checkType(Node $type): void $fqn = $type->getAttribute(XphpSourceParser::ATTR_SUSPECT_UNDECLARED_TYPE); if (is_string($fqn) && !$this->hierarchy->isDeclared($fqn)) { $this->violations[] = [ - 'message' => self::undeclaredTypeMessage($type->toString(), $this->templateFqn), + 'message' => self::undeclaredTypeMessage($type->toString(), $this->context), 'line' => $type->getStartLine(), ]; } diff --git a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php index 22321f9..f243907 100644 --- a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php +++ b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php @@ -230,6 +230,46 @@ public function testUndeclaredTypesAreCaughtInEveryMemberPosition(): void ); } + public function testUndeclaredTypesInMethodAndFunctionGenericsAreCollected(): void + { + // A generic method on a plain class and a free generic function — both + // outside any generic template — are validated by the method-level pass. + $diagnostics = $this->check('undeclared_type_param_method'); + + $contexts = []; + foreach ($diagnostics->all() as $d) { + self::assertSame(UndeclaredTypeParameterValidator::CODE_UNDECLARED_TYPE, $d->code); + self::assertNotNull($d->location); + preg_match('/Type `(\w+)` used in (.+?) is not/', $d->message, $m); + $contexts[$m[1]] = $m[2]; + } + ksort($contexts); + self::assertSame(['B', 'C', 'D', 'E'], array_keys($contexts)); + self::assertSame('method `pick`', $contexts['B']); + self::assertSame('function `wrap`', $contexts['C']); + self::assertSame('closure', $contexts['D']); + self::assertSame('arrow function', $contexts['E']); + } + + public function testGenericMethodInsideGenericTemplateIsReportedExactlyOnce(): void + { + // The class-member walk owns it; the method-level pass skips generics nested + // in a generic template — so no double report. + $diagnostics = $this->check('undeclared_type_param_nested_method'); + + self::assertCount(1, $diagnostics->all()); + self::assertSame(UndeclaredTypeParameterValidator::CODE_UNDECLARED_TYPE, $diagnostics->all()[0]->code); + self::assertStringContainsString('Type `Stray`', $diagnostics->all()[0]->message); + } + + public function testCompileStillThrowsOnUndeclaredTypeInGenericFunction(): void + { + // The first finding in source order is `B` in method `pick`. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Type `B` used in method `pick`'); + $this->compileFixture('undeclared_type_param_method'); + } + public function testImportedAndFullyQualifiedTypesAreNotFlaggedAsUndeclared(): void { $diagnostics = $this->check('undeclared_type_param_escape'); @@ -243,7 +283,7 @@ public function testCompileStillThrowsOnUndeclaredTypeParameter(): void // Throws the FIRST finding (the `T` in add(), before `U` in wrap()), not a // silent broken emit. $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Type `T` used in `App\\Undeclared\\CollectionInterface`'); + $this->expectExceptionMessage('Type `T` used in template `App\\Undeclared\\CollectionInterface`'); $this->compileFixture('undeclared_type_param'); } diff --git a/test/fixture/check/undeclared_type_param_method/source/Util.xphp b/test/fixture/check/undeclared_type_param_method/source/Util.xphp new file mode 100644 index 0000000..5a8dcaa --- /dev/null +++ b/test/fixture/check/undeclared_type_param_method/source/Util.xphp @@ -0,0 +1,28 @@ +(B $x): A + { + return $x; + } +} + +// Free generic function: `C` is a stray parameter. +function wrap(C $x): A +{ + return $x; +} + +// Generic closure and arrow: `D` and `E` are stray parameters. +$closure = function(D $x): A { + return $x; +}; + +$arrow = fn(E $x): A => $x; diff --git a/test/fixture/check/undeclared_type_param_nested_method/source/Box.xphp b/test/fixture/check/undeclared_type_param_nested_method/source/Box.xphp new file mode 100644 index 0000000..23b8218 --- /dev/null +++ b/test/fixture/check/undeclared_type_param_nested_method/source/Box.xphp @@ -0,0 +1,14 @@ + +{ + // A generic method nested in a generic template. Its stray `Stray` must be + // reported exactly once (by the template's member walk), not twice. + public function map(Stray $x): K + { + } +} From dbb4ddab3e44e083e0bf0fe368fb83d0a9e303b7 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 18 Jun 2026 17:04:13 +0000 Subject: [PATCH 025/114] feat(check): flag undeclared names in generic bounds and defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the undeclared-type check to a type parameter's bound and default — e.g. `class Box` or `class Pair`, 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. ``) 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) --- docs/errors.md | 2 +- src/Transpiler/Monomorphize/Registry.php | 1 + src/Transpiler/Monomorphize/TypeRef.php | 6 ++ .../UndeclaredTypeParameterValidator.php | 69 ++++++++++++++++++- .../Monomorphize/XphpSourceParser.php | 24 ++++++- .../Monomorphize/CheckPassIntegrationTest.php | 57 +++++++++++++++ .../check/undeclared_bound/source/Box.xphp | 10 +++ .../undeclared_bound_clean/source/Ok.xphp | 15 ++++ .../undeclared_bound_dedup/source/Dup.xphp | 10 +++ .../undeclared_bound_nested/source/Box.xphp | 15 ++++ .../check/undeclared_default/source/Pair.xphp | 10 +++ 11 files changed, 215 insertions(+), 4 deletions(-) create mode 100644 test/fixture/check/undeclared_bound/source/Box.xphp create mode 100644 test/fixture/check/undeclared_bound_clean/source/Ok.xphp create mode 100644 test/fixture/check/undeclared_bound_dedup/source/Dup.xphp create mode 100644 test/fixture/check/undeclared_bound_nested/source/Box.xphp create mode 100644 test/fixture/check/undeclared_default/source/Pair.xphp diff --git a/docs/errors.md b/docs/errors.md index 66abbb5..1e388ea 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -41,7 +41,7 @@ The `json` and `github` formats tag each diagnostic with a stable code: | `xphp.variance_position` | a `+T`/`-T` parameter appears in a position its variance forbids | | `xphp.inner_variance` | variance is violated through another generic's slot (composition) | | `xphp.undefined_template` | a generic was instantiated but never declared | -| `xphp.undeclared_type` | a generic member names a type that is neither a declared type parameter nor a known type — e.g. `interface Foo { add(T $x); }` where `T` is a stray/typo'd parameter (would otherwise compile to a reference to a non-existent class). Imported (`use`) and fully-qualified names are never flagged. A real class referenced bare must be imported or fully-qualified — including a class in the *same namespace* that lives in a plain `.php` file (which `xphp check` doesn't scan), even though PHP itself wouldn't require the `use` | +| `xphp.undeclared_type` | a generic member, bound, or default names a type that is neither a declared type parameter nor a known type — e.g. `interface Foo { add(T $x); }` or `class Box` where the name is a stray/typo'd parameter (would otherwise compile to a reference to a non-existent class). Imported (`use`) and fully-qualified names are never flagged. A real class referenced bare must be imported or fully-qualified — including a class in the *same namespace* that lives in a plain `.php` file (which `xphp check` doesn't scan), even though PHP itself wouldn't require the `use` | | `xphp.duplicate_generic_function` | the same generic function is declared in two files | | `xphp.closure_this_capture` | a generic closure/arrow used via turbofish captures `$this` (unsupported) | | `xphp.static_closure` | a generic `static` closure used via turbofish (unsupported) | diff --git a/src/Transpiler/Monomorphize/Registry.php b/src/Transpiler/Monomorphize/Registry.php index 80f0cd3..ebf7512 100644 --- a/src/Transpiler/Monomorphize/Registry.php +++ b/src/Transpiler/Monomorphize/Registry.php @@ -420,6 +420,7 @@ public function validateUndeclaredTypeParameters(): void foreach ($this->definitions as $templateFqn => $definition) { UndeclaredTypeParameterValidator::assert( $definition->templateAst, + $definition->typeParams, $templateFqn, $this->hierarchy, $this->diagnostics, diff --git a/src/Transpiler/Monomorphize/TypeRef.php b/src/Transpiler/Monomorphize/TypeRef.php index ebe605b..68a2889 100644 --- a/src/Transpiler/Monomorphize/TypeRef.php +++ b/src/Transpiler/Monomorphize/TypeRef.php @@ -23,6 +23,12 @@ public function __construct( public array $args = [], public bool $isScalar = false, public bool $isTypeParam = false, + // Set by the parser for a bare, single-segment, non-imported bound/default + // name used inside a generic context that is NOT a declared type parameter — + // the bound/default analogue of XphpSourceParser::ATTR_SUSPECT_UNDECLARED_TYPE + // (TypeRefs carry no AST attributes). The undeclared-type validator flags it + // when `name` resolves to no declared/built-in type. + public bool $suspectUndeclared = false, ) { } diff --git a/src/Transpiler/Monomorphize/UndeclaredTypeParameterValidator.php b/src/Transpiler/Monomorphize/UndeclaredTypeParameterValidator.php index b143926..ee6fa8f 100644 --- a/src/Transpiler/Monomorphize/UndeclaredTypeParameterValidator.php +++ b/src/Transpiler/Monomorphize/UndeclaredTypeParameterValidator.php @@ -60,10 +60,14 @@ private function __construct( } /** - * Validate a generic class/interface/trait template's member signatures. + * Validate a generic class/interface/trait template's member signatures and the + * type names used in its type-parameter bounds and defaults. + * + * @param list $params the template's declared type parameters */ public static function assert( ClassLike $node, + array $params, string $templateFqn, TypeHierarchy $hierarchy, ?DiagnosticCollector $diagnostics = null, @@ -71,6 +75,7 @@ public static function assert( ): void { $validator = new self($hierarchy, 'template `' . $templateFqn . '`'); $validator->collect($node); + $validator->collectBoundsAndDefaults($params, $node->getStartLine()); self::report($validator->violations, $diagnostics, $file); } @@ -276,6 +281,68 @@ private function walkBodyForNestedClosures(mixed $node): void } } + /** + * Check the type names used in the declared parameters' bounds and defaults + * (which are TypeRef trees, not AST type nodes — they carry the suspect flag on + * the TypeRef itself). All locate at the declaration line; duplicates of the + * same undeclared name (e.g. `` or two params bounded by `Bad`) + * collapse to one finding. + * + * @param list $params + */ + private function collectBoundsAndDefaults(array $params, int $declarationLine): void + { + $seen = []; + foreach ($params as $param) { + if ($param->bound !== null) { + $this->collectSuspectInBound($param->bound, $declarationLine, $seen); + } + if ($param->default !== null) { + $this->collectSuspectInTypeRef($param->default, $declarationLine, $seen); + } + } + } + + /** @param array $seen */ + private function collectSuspectInBound(BoundExpr $bound, int $line, array &$seen): void + { + if ($bound instanceof BoundLeaf) { + $this->collectSuspectInTypeRef($bound->type, $line, $seen); + return; + } + // @infection-ignore-all LogicalOrAllSubExprNegation -- a non-leaf BoundExpr is + // always an Intersection or a Union, so this assert is a phpstan type-narrowing + // tautology; negating its operands still holds. Purely a type guard, not behavior. + assert($bound instanceof BoundIntersection || $bound instanceof BoundUnion); + foreach ($bound->operands as $operand) { + $this->collectSuspectInBound($operand, $line, $seen); + } + } + + /** @param array $seen */ + private function collectSuspectInTypeRef(TypeRef $ref, int $line, array &$seen): void + { + if ($ref->suspectUndeclared && !$this->hierarchy->isDeclared($ref->name) && !isset($seen[$ref->name])) { + // @infection-ignore-all TrueValue -- only the KEY's presence matters (isset above); + // the stored value is never read, so true vs false is observably identical. + $seen[$ref->name] = true; + $this->violations[] = [ + 'message' => self::undeclaredTypeMessage(self::shortName($ref->name), $this->context), + 'line' => $line, + ]; + } + foreach ($ref->args as $inner) { + $this->collectSuspectInTypeRef($inner, $line, $seen); + } + } + + private static function shortName(string $fqn): string + { + $pos = strrpos($fqn, '\\'); + + return $pos === false ? $fqn : substr($fqn, $pos + 1); + } + private function checkType(Node $type): void { if ($type instanceof Name) { diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 1fba5da..1b672c1 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -1775,7 +1775,10 @@ private function buildBoundExprNode(array $node): BoundExpr ? $node['name'] : $this->resolveNameOnly($node['name']); $resolvedArgs = $this->resolveTypeRefList($node['args']); - return new BoundLeaf(new TypeRef($fqn, $resolvedArgs)); + $suspect = !$node['isFq'] + && !$this->isEnclosingTypeParam($node['name']) + && $this->isSuspectUndeclared($node['name']); + return new BoundLeaf(new TypeRef($fqn, $resolvedArgs, suspectUndeclared: $suspect)); } $operands = []; foreach ($node['operands'] as $op) { @@ -1834,7 +1837,24 @@ private function resolveTypeRef(TypeRef $ref): TypeRef return new TypeRef($lower, $resolvedArgs, isScalar: true); } - return new TypeRef($this->ctx->resolveAgainstContext($name), $resolvedArgs); + return new TypeRef( + $this->ctx->resolveAgainstContext($name), + $resolvedArgs, + suspectUndeclared: $this->isSuspectUndeclared($name), + ); + } + + /** + * A bare, single-segment, non-imported class name used inside a generic + * context — the suspect condition shared by the bound/default TypeRef path + * and {@see markName}'s ATTR_SUSPECT_UNDECLARED_TYPE tag. Callers have + * already excluded scalars and enclosing type-params. + */ + private function isSuspectUndeclared(string $name): bool + { + return strpos($name, '\\') === false + && $this->hasEnclosingTypeParams() + && !$this->ctx->isImported($name); } private function isEnclosingTypeParam(string $name): bool diff --git a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php index f243907..85d2e44 100644 --- a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php +++ b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php @@ -270,6 +270,63 @@ public function testCompileStillThrowsOnUndeclaredTypeInGenericFunction(): void $this->compileFixture('undeclared_type_param_method'); } + public function testUndeclaredNameInABoundIsCollected(): void + { + $diagnostics = $this->check('undeclared_bound'); + + self::assertCount(1, $diagnostics->all()); + self::assertSame(UndeclaredTypeParameterValidator::CODE_UNDECLARED_TYPE, $diagnostics->all()[0]->code); + self::assertStringContainsString('Type `Nonexistent`', $diagnostics->all()[0]->message); + } + + public function testUndeclaredNameInADefaultIsCollected(): void + { + $diagnostics = $this->check('undeclared_default'); + + self::assertCount(1, $diagnostics->all()); + self::assertSame(UndeclaredTypeParameterValidator::CODE_UNDECLARED_TYPE, $diagnostics->all()[0]->code); + self::assertStringContainsString('Type `Nonexistent`', $diagnostics->all()[0]->message); + } + + public function testUndeclaredNamesInIntersectionBoundAndGenericArgAreCollected(): void + { + // `Bad1` is a leaf of an intersection bound; `Bad2` is a type argument of a + // declared generic bound (`Holder`). Both stray names are reported. + $diagnostics = $this->check('undeclared_bound_nested'); + + $names = []; + foreach ($diagnostics->all() as $d) { + self::assertSame(UndeclaredTypeParameterValidator::CODE_UNDECLARED_TYPE, $d->code); + preg_match('/Type `(\w+)`/', $d->message, $m); + $names[$m[1]] = true; + } + ksort($names); + self::assertSame(['Bad1', 'Bad2'], array_keys($names)); + } + + public function testBuiltinImportedAndParamRefBoundsAndDefaultsAreClean(): void + { + $diagnostics = $this->check('undeclared_bound_clean'); + + self::assertFalse($diagnostics->hasErrors()); + self::assertSame([], $diagnostics->all()); + } + + public function testSameUndeclaredNameInBoundAndDefaultIsReportedOnce(): void + { + $diagnostics = $this->check('undeclared_bound_dedup'); + + self::assertCount(1, $diagnostics->all()); + self::assertStringContainsString('Type `Bad`', $diagnostics->all()[0]->message); + } + + public function testCompileStillThrowsOnUndeclaredBound(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Type `Nonexistent` used in template `App\\BadBound\\Box`'); + $this->compileFixture('undeclared_bound'); + } + public function testImportedAndFullyQualifiedTypesAreNotFlaggedAsUndeclared(): void { $diagnostics = $this->check('undeclared_type_param_escape'); diff --git a/test/fixture/check/undeclared_bound/source/Box.xphp b/test/fixture/check/undeclared_bound/source/Box.xphp new file mode 100644 index 0000000..89d1d95 --- /dev/null +++ b/test/fixture/check/undeclared_bound/source/Box.xphp @@ -0,0 +1,10 @@ + +{ +} diff --git a/test/fixture/check/undeclared_bound_clean/source/Ok.xphp b/test/fixture/check/undeclared_bound_clean/source/Ok.xphp new file mode 100644 index 0000000..bd1af55 --- /dev/null +++ b/test/fixture/check/undeclared_bound_clean/source/Ok.xphp @@ -0,0 +1,15 @@ + +{ +} diff --git a/test/fixture/check/undeclared_bound_dedup/source/Dup.xphp b/test/fixture/check/undeclared_bound_dedup/source/Dup.xphp new file mode 100644 index 0000000..ecefc35 --- /dev/null +++ b/test/fixture/check/undeclared_bound_dedup/source/Dup.xphp @@ -0,0 +1,10 @@ + +{ +} diff --git a/test/fixture/check/undeclared_bound_nested/source/Box.xphp b/test/fixture/check/undeclared_bound_nested/source/Box.xphp new file mode 100644 index 0000000..ce26ff1 --- /dev/null +++ b/test/fixture/check/undeclared_bound_nested/source/Box.xphp @@ -0,0 +1,15 @@ + +{ +} + +// Intersection bound with a stray leaf (`Bad1`) and a declared generic whose +// type ARGUMENT is stray (`Bad2`). Both are reported. +class Box> +{ +} diff --git a/test/fixture/check/undeclared_default/source/Pair.xphp b/test/fixture/check/undeclared_default/source/Pair.xphp new file mode 100644 index 0000000..035296f --- /dev/null +++ b/test/fixture/check/undeclared_default/source/Pair.xphp @@ -0,0 +1,10 @@ + +{ +} From 82a5f78ad5b7704ef5e419ea48d37d3ca09b931f Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 18 Jun 2026 17:15:43 +0000 Subject: [PATCH 026/114] feat(check): report too many type arguments instead of silently truncating `Box::` for a one-parameter `Box` used to silently drop the extra argument and proceed as `Box`. 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) --- docs/errors.md | 1 + src/Transpiler/Monomorphize/Registry.php | 41 +++++++++++++++++-- .../Monomorphize/CheckPassIntegrationTest.php | 25 +++++++++++ .../check/too_many_type_args/source/Box.xphp | 10 +++++ .../check/too_many_type_args/source/Use.xphp | 9 ++++ 5 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 test/fixture/check/too_many_type_args/source/Box.xphp create mode 100644 test/fixture/check/too_many_type_args/source/Use.xphp diff --git a/docs/errors.md b/docs/errors.md index 1e388ea..493f5a4 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -38,6 +38,7 @@ The `json` and `github` formats tag each diagnostic with a stable code: | `xphp.bound_violation` | a concrete type argument doesn't satisfy its parameter's bound | | `xphp.default_bound_violation` | a parameter's default doesn't satisfy its own bound | | `xphp.missing_type_argument` | a required type argument was omitted and has no default | +| `xphp.too_many_type_arguments` | more type arguments were supplied than the template declares (e.g. `Box::` for a one-parameter `Box`) | | `xphp.variance_position` | a `+T`/`-T` parameter appears in a position its variance forbids | | `xphp.inner_variance` | variance is violated through another generic's slot (composition) | | `xphp.undefined_template` | a generic was instantiated but never declared | diff --git a/src/Transpiler/Monomorphize/Registry.php b/src/Transpiler/Monomorphize/Registry.php index ebf7512..896e4f1 100644 --- a/src/Transpiler/Monomorphize/Registry.php +++ b/src/Transpiler/Monomorphize/Registry.php @@ -24,6 +24,7 @@ final class Registry /** Stable diagnostic codes (machine identifiers tooling can match on). */ public const CODE_BOUND_VIOLATION = 'xphp.bound_violation'; public const CODE_MISSING_TYPE_ARGUMENT = 'xphp.missing_type_argument'; + public const CODE_TOO_MANY_TYPE_ARGUMENTS = 'xphp.too_many_type_arguments'; public const CODE_DEFAULT_BOUND_VIOLATION = 'xphp.default_bound_violation'; public const CODE_UNDEFINED_TEMPLATE = 'xphp.undefined_template'; @@ -206,7 +207,24 @@ public static function padArgsWithDefaults( ): array { $supplied = count($args); $needed = count($params); - if ($supplied >= $needed) { + if ($supplied > $needed) { + // Over-arity: more type arguments than the template declares. Reported + // instead of silently truncating the extras. Returning $args unchanged + // lets the downstream arity guards skip specialization for this instantiation. + $message = self::tooManyTypeArgumentsMessage($templateLabel, $supplied, $needed); + if ($diagnostics !== null) { + $diagnostics->add(new Diagnostic( + Severity::Error, + self::CODE_TOO_MANY_TYPE_ARGUMENTS, + $message, + $callSite, + )); + + return $args; + } + throw new RuntimeException($message); + } + if ($supplied === $needed) { return $args; } @@ -235,6 +253,22 @@ public static function padArgsWithDefaults( return $padded; } + /** + * Single source of truth for the too-many-type-arguments message. + */ + private static function tooManyTypeArgumentsMessage( + string $templateLabel, + int $supplied, + int $needed, + ): string { + return sprintf( + 'Generic template "%s" declares %d type parameter(s) but was instantiated with %d type argument(s); remove the extra argument(s).', + $templateLabel, + $needed, + $supplied, + ); + } + /** * Single source of truth for the missing-required-type-argument message. */ @@ -516,8 +550,9 @@ public static function checkBounds( ?DiagnosticCollector $diagnostics = null, ?SourceLocation $callSite = null, ): void { - // Arity mismatch is a different error class (caught upstream); skip silently here - // so that the existing pipeline can produce the more specific message. + // Arity mismatch is a different error class, reported upstream by + // padArgsWithDefaults (under-arity → missing-type-argument, over-arity → + // too-many-type-arguments); skip the bound check here for the partial tuple. if (count($typeParams) !== count($args)) { return; } diff --git a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php index 85d2e44..eaee99a 100644 --- a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php +++ b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php @@ -327,6 +327,31 @@ public function testCompileStillThrowsOnUndeclaredBound(): void $this->compileFixture('undeclared_bound'); } + public function testTooManyTypeArgumentsAreCollected(): void + { + // Box declares one parameter; two instantiations pass two args each → two + // findings (not silent truncation), at their call-site lines. + $diagnostics = $this->check('too_many_type_args'); + + self::assertCount(2, $diagnostics->all()); + $lines = []; + foreach ($diagnostics->all() as $d) { + self::assertSame(Registry::CODE_TOO_MANY_TYPE_ARGUMENTS, $d->code); + self::assertNotNull($d->location); + self::assertStringEndsWith('Use.xphp', $d->location->file); + $lines[] = $d->location->line; + } + sort($lines); + self::assertSame([8, 9], $lines); + } + + public function testCompileStillThrowsOnTooManyTypeArguments(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('declares 1 type parameter(s) but was instantiated with 2'); + $this->compileFixture('too_many_type_args'); + } + public function testImportedAndFullyQualifiedTypesAreNotFlaggedAsUndeclared(): void { $diagnostics = $this->check('undeclared_type_param_escape'); diff --git a/test/fixture/check/too_many_type_args/source/Box.xphp b/test/fixture/check/too_many_type_args/source/Box.xphp new file mode 100644 index 0000000..a097b0d --- /dev/null +++ b/test/fixture/check/too_many_type_args/source/Box.xphp @@ -0,0 +1,10 @@ + +{ + public T $item; +} diff --git a/test/fixture/check/too_many_type_args/source/Use.xphp b/test/fixture/check/too_many_type_args/source/Use.xphp new file mode 100644 index 0000000..4e1c289 --- /dev/null +++ b/test/fixture/check/too_many_type_args/source/Use.xphp @@ -0,0 +1,9 @@ +(); +$b = new Box::(); From cc41010700d43df5a325c891276d513fc9d102e5 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 18 Jun 2026 17:20:30 +0000 Subject: [PATCH 027/114] fix(check): don't flag a scalar bound as an undeclared type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A scalar bound like `class Box` 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) --- src/Transpiler/Monomorphize/XphpSourceParser.php | 3 +++ test/fixture/check/undeclared_bound_clean/source/Ok.xphp | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 1b672c1..e726c69 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -1852,7 +1852,10 @@ private function resolveTypeRef(TypeRef $ref): TypeRef */ private function isSuspectUndeclared(string $name): bool { + // @infection-ignore-all UnwrapStrToLower -- scalar-type tokens already arrive + // lowercased from the grammar (same rationale as resolveTypeRef's scalar guard). return strpos($name, '\\') === false + && !in_array(strtolower($name), XphpSourceParser::SCALAR_TYPES, true) && $this->hasEnclosingTypeParams() && !$this->ctx->isImported($name); } diff --git a/test/fixture/check/undeclared_bound_clean/source/Ok.xphp b/test/fixture/check/undeclared_bound_clean/source/Ok.xphp index bd1af55..8c78c59 100644 --- a/test/fixture/check/undeclared_bound_clean/source/Ok.xphp +++ b/test/fixture/check/undeclared_bound_clean/source/Ok.xphp @@ -8,8 +8,8 @@ use App\Other\Real; // Clean: built-in bound (Stringable), imported bound (Real), a parameter // referencing an earlier parameter as its default (B = A) and as its bound -// (C: A), and a multi-segment (namespaced) bound (Sub\Thing) — none are -// single-segment strays, so none are flagged. -class Ok +// (C: A), a multi-segment (namespaced) bound (Sub\Thing), and a scalar bound +// (E: int) — none are single-segment class strays, so none are flagged here. +class Ok { } From 5bae46d86a05a7fc744bf02498584a0f2fa25165 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 18 Jun 2026 17:22:04 +0000 Subject: [PATCH 028/114] docs(changelog): record the undeclared-type and too-many-args checks Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e473069..baeee90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 binary or a failed run is a non-failing Warning. Opt out with `--no-phpstan`; override discovery with `--phpstan-bin` / `--phpstan-config`. +### Fixed + +- **Undeclared type parameters are now rejected** instead of silently compiling to + a reference to a non-existent class. A bare, single-segment, non-imported type + name used in a generic member, bound, or default that is neither a declared type + parameter nor a known type — e.g. `interface Foo { add(T $x); }` or + `class Box` — fails `xphp compile` and is reported by `xphp check` + as `xphp.undeclared_type`. Covers class/interface/trait members and method, + function, closure, and arrow generics. Imported (`use`) and fully-qualified names + are unaffected. +- **Too many type arguments are now rejected** instead of silently truncated: + `Box::` for a one-parameter `Box` reports `xphp.too_many_type_arguments`. + ## [0.2.0] The first feature release on top of the core monomorphization pipeline. From 68f19117c871fe9893ad26012f71b2d30df00ac1 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 18 Jun 2026 18:13:21 +0000 Subject: [PATCH 029/114] docs: add Architecture Decision Records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CONTRIBUTING.md | 9 +- docs/adr/0000-adr-template.md | 50 ++++++++ ...0001-monomorphization-over-type-erasure.md | 96 ++++++++++++++++ docs/adr/0002-build-time-transpiler.md | 79 +++++++++++++ docs/adr/0003-rfc-aligned-turbofish-syntax.md | 79 +++++++++++++ .../0004-marker-interfaces-for-instanceof.md | 76 +++++++++++++ .../adr/0005-nominal-erased-bound-checking.md | 90 +++++++++++++++ .../0006-bounded-specialization-depth-cap.md | 76 +++++++++++++ docs/adr/0007-xphp-check-gate.md | 81 +++++++++++++ .../0008-collect-or-throw-diagnostic-seam.md | 81 +++++++++++++ docs/adr/0009-phpstan-over-compiled-output.md | 107 ++++++++++++++++++ ...10-undeclared-type-and-arity-validation.md | 91 +++++++++++++++ docs/adr/0011-phar-distribution.md | 78 +++++++++++++ docs/adr/0012-engineering-quality-bar.md | 82 ++++++++++++++ docs/adr/README.md | 32 ++++++ docs/index.md | 1 + 16 files changed, 1107 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0000-adr-template.md create mode 100644 docs/adr/0001-monomorphization-over-type-erasure.md create mode 100644 docs/adr/0002-build-time-transpiler.md create mode 100644 docs/adr/0003-rfc-aligned-turbofish-syntax.md create mode 100644 docs/adr/0004-marker-interfaces-for-instanceof.md create mode 100644 docs/adr/0005-nominal-erased-bound-checking.md create mode 100644 docs/adr/0006-bounded-specialization-depth-cap.md create mode 100644 docs/adr/0007-xphp-check-gate.md create mode 100644 docs/adr/0008-collect-or-throw-diagnostic-seam.md create mode 100644 docs/adr/0009-phpstan-over-compiled-output.md create mode 100644 docs/adr/0010-undeclared-type-and-arity-validation.md create mode 100644 docs/adr/0011-phar-distribution.md create mode 100644 docs/adr/0012-engineering-quality-bar.md create mode 100644 docs/adr/README.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3539391..e5ced8b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,4 +29,11 @@ docker compose run --rm php85 make test/unit/php85 CI gates every PR on these targets. `infection.json5` carries a curated set of per-mutator `ignore` rules for genuinely-equivalent / defensive -mutations so the report only surfaces real test gaps. \ No newline at end of file +mutations so the report only surfaces real test gaps. + +## Architecture decisions + +Architecturally significant decisions — the ones that are expensive to reverse — +are recorded as [Architecture Decision Records](docs/adr/README.md). When you make +such a decision, add a new ADR under `docs/adr/` (copy +[`docs/adr/0000-adr-template.md`](docs/adr/0000-adr-template.md)). \ No newline at end of file diff --git a/docs/adr/0000-adr-template.md b/docs/adr/0000-adr-template.md new file mode 100644 index 0000000..e1baee1 --- /dev/null +++ b/docs/adr/0000-adr-template.md @@ -0,0 +1,50 @@ +# NNNN. Short decision title + +- Status: Proposed | Accepted — YYYY-MM | Superseded by ADR-NNNN + +## Context and Problem Statement + +What forces are at play? What problem is being solved? One or two short +paragraphs. State it neutrally enough that the chosen option isn't a foregone +conclusion. + +## Decision Drivers + +- A property the solution must have, or a goal it serves. +- … + +## Considered Options + +- Option A +- Option B +- … + +## Decision Outcome + +Chosen: "Option A", because . + +### Consequences + +- Good, because <…>. +- Trade-off / bad, because <…>. + +### Confirmation + +How the decision is enforced or verified in the codebase (a test, a CI gate, a +fixture, an invariant). + +## Pros and Cons of the Options + +### Option A + +- Good: <…> +- Bad: <…> + +### Option B + +- Good: <…> +- Bad: <…> + +## More Information + +Links to the relevant source, docs, or external references (e.g. a PHP RFC). diff --git a/docs/adr/0001-monomorphization-over-type-erasure.md b/docs/adr/0001-monomorphization-over-type-erasure.md new file mode 100644 index 0000000..5c43a2b --- /dev/null +++ b/docs/adr/0001-monomorphization-over-type-erasure.md @@ -0,0 +1,96 @@ +# 1. Monomorphization over type erasure + +- Status: Accepted — 2026-05 + +## Context and Problem Statement + +PHP has no generics. xphp adds them as a source-level language feature, which +forces a foundational question: what should a generic *become* at runtime? Two +families of answer dominate the prior art. **Erasure** (Java, the PHP +bound-erased-generics RFC) keeps generic identity at compile time only — at +runtime a `Collection` is just a `Collection`, and the type argument is +gone. **Monomorphization** (C++ templates, Rust) stamps out a distinct concrete +type per instantiation — `Collection` becomes a real `Collection_User` +class with `User` baked into every signature. + +The choice is effectively irreversible: it defines what the generated code is, +what reflection sees, whether `instanceof T` can work, and how runtime type +errors surface. Everything else in xphp is built on top of it. + +## Decision Drivers + +- **Zero runtime penalty** — the result should run exactly like hand-written PHP, + with no dispatcher, reflection, or custom runtime in the hot path. +- **Runtime safety that doesn't lie** — passing the wrong type should raise a real + PHP `TypeError` at the boundary, not silently degrade to a bag of `mixed`. +- **Progressive enhancement** — output must be ordinary PHP a team can drop into + an existing project with no new runtime dependency. +- **Reflection fidelity** — tooling, serializers, and DI containers should see the + concrete type. + +## Considered Options + +- **Monomorphization** — one specialized class per instantiation. +- **Type erasure** — generic identity at compile time only; one runtime class. +- **Hybrid** — erase the class, carry type identity in a metadata sidecar. + +## Decision Outcome + +Chosen: **monomorphization**, because it is the only option that delivers all +four drivers at once. `class Box` plus `new Box::()` compiles to a +concrete `Box`-specialization whose members are typed with `Plastic`; PHP's own +type system then enforces them, reflection reports them, and `opcache` optimizes +the result like any other class. + +### Consequences + +- Good: the runtime sees only vanilla PHP — no dependency, no overhead, native + `TypeError` enforcement, accurate reflection. +- Good: reified types fall out for free — `new T(...)`, `T::class`, and + `instanceof` against a concrete arg work because `T` literally *is* the concrete + class in the generated code. +- Trade-off: each distinct instantiation emits a separate class file, so the + compiled output grows with the number of instantiations. +- Trade-off: compilation does real work (a fixed-point specialization loop), which + must be bounded against pathological inputs — see + [ADR-0006](0006-bounded-specialization-depth-cap.md). +- Trade-off: runtime semantics diverge from the erasure-based PHP RFC. The surface + syntax is kept compatible (see [ADR-0003](0003-rfc-aligned-turbofish-syntax.md)), + but the runtime models differ by design. + +### Confirmation + +The pipeline that performs specialization lives in +[`src/Transpiler/Monomorphize/`](../../src/Transpiler/Monomorphize/Compiler.php); +end-to-end fixtures compile real `.xphp` and snapshot the emitted PHP. See +[How it works](../guides/how-it-works.md) and +[Runtime semantics](../guides/runtime-semantics.md). + +## Pros and Cons of the Options + +### Monomorphization + +- Good: zero-overhead, reified, reflection-accurate, no runtime dependency. +- Bad: larger output; non-trivial compile-time work; runtime diverges from the RFC. + +### Type erasure + +- Good: tiny output, trivial compile, closest to a future native-PHP runtime. +- Bad: `instanceof T` / `T::class` impossible; reflection sees a raw `Collection`; + composition mistakes surface late. + +### Hybrid (metadata sidecar) + +- Good: smaller output than full monomorphization while keeping some identity. +- Bad: two models to keep in sync; a metadata registry to persist and invalidate; + runtime/metadata gaps to bridge. + +## More Information + +- [PHP RFC: bound-erased generic types](https://wiki.php.net/rfc/bound_erased_generic_types) + — the erasure model xphp deliberately diverges from at runtime. +- An earlier prototype implemented generics via runtime attributes and reflection; + it was fully superseded by the monomorphization compiler, which also let the + project drop its reflection-library dependencies. +- [ADR-0002](0002-build-time-transpiler.md) — the build-time transpiler model that + makes this practical. diff --git a/docs/adr/0002-build-time-transpiler.md b/docs/adr/0002-build-time-transpiler.md new file mode 100644 index 0000000..da25b50 --- /dev/null +++ b/docs/adr/0002-build-time-transpiler.md @@ -0,0 +1,79 @@ +# 2. A build-time transpiler that emits plain PHP + +- Status: Accepted — 2026-05 + +## Context and Problem Statement + +Given monomorphization ([ADR-0001](0001-monomorphization-over-type-erasure.md)), +*when* and *how* does the specialization happen? It could run at runtime (a +library that intercepts class loading and generates specializations on demand), +or ahead of time (a compiler that reads source and writes source). The answer +determines what a consuming project depends on, how the output interacts with +`opcache` and static analyzers, and whether xphp is something you *run* or +something you *build with*. + +## Decision Drivers + +- No runtime dependency on xphp in the shipped application. +- Output that existing PHP tooling (opcache, PHPStan, IDEs) understands natively. +- A clear, debuggable artifact — you can open and read the generated PHP. +- Deterministic, cacheable builds. + +## Considered Options + +- **Build-time transpiler** — `xphp compile ` reads `.xphp` + and writes plain `.php`. +- **Runtime library** — generate specializations lazily during autoloading. +- **A PHP extension** — implement generics in C in the engine. + +## Decision Outcome + +Chosen: a **build-time transpiler**. `.xphp` files are compiled offline into +ordinary PHP — rewritten user files under a `dist/` directory and generated +specialized classes under a cache directory — with the generic sugar fully +resolved away. The application ships and runs the plain PHP; xphp is a +development/CI-time tool, not a runtime. + +### Consequences + +- Good: the runtime has zero xphp dependency; the output is normal PHP that + `opcache`, PHPStan, and IDEs handle with no special support. +- Good: the generated code is inspectable and debuggable — no magic at runtime. +- Good: it makes the PHPStan-over-output layer + ([ADR-0009](0009-phpstan-over-compiled-output.md)) possible at all, since there + is concrete PHP to analyze. +- Trade-off: a build step is required before the code runs (and before stack + traces/line numbers map cleanly — source maps are roadmap work). +- Trade-off: analysis and tooling see only what was actually instantiated and + compiled, not the templates directly. + +### Confirmation + +The CLI entry point is [`bin/xphp`](../../bin/xphp); the compile command is +[`CompileCommand`](../../src/Console/Command/CompileCommand.php). See +[Getting started](../getting-started.md). + +## Pros and Cons of the Options + +### Build-time transpiler + +- Good: no runtime dep; tooling-native output; inspectable; cacheable. +- Bad: needs a build step; line/stack mapping back to `.xphp` is extra work. + +### Runtime library + +- Good: no separate build step. +- Bad: per-request generation overhead; fights opcache; static analyzers can't see + generated types; a hard runtime dependency. + +### PHP extension + +- Good: deepest integration, potentially best performance. +- Bad: enormous scope and maintenance; install friction; ties the project to engine + internals — out of proportion for a language experiment. + +## More Information + +- [How it works](../guides/how-it-works.md) — the parse → specialize → emit flow. +- `composer.json` declares the project a `library` of type *transpiler / + monomorphization*; the compiled output targets plain PHP. diff --git a/docs/adr/0003-rfc-aligned-turbofish-syntax.md b/docs/adr/0003-rfc-aligned-turbofish-syntax.md new file mode 100644 index 0000000..1f5f4cb --- /dev/null +++ b/docs/adr/0003-rfc-aligned-turbofish-syntax.md @@ -0,0 +1,79 @@ +# 3. RFC-aligned turbofish surface syntax + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +xphp adds generic *syntax* to PHP, and there is an active PHP RFC for +bound-erased generic types. xphp's runtime model diverges from that RFC +(monomorphization vs erasure — [ADR-0001](0001-monomorphization-over-type-erasure.md)), +but the *surface syntax* is a separate choice. If xphp invents its own spelling +for instantiation and bounds, then `.xphp` source becomes a dead end the day PHP +ships native generics. If it tracks the RFC, today's source has a path to a +future native runtime. + +The specific tension is at call/`new` sites: `new Box()` is ambiguous +with the comparison/`<` grammar, which is exactly why the RFC uses *turbofish* +`Name::<...>`. + +## Decision Drivers + +- Forward compatibility: `.xphp` source should stay valid against a future native + PHP generics runtime. +- No grammar ambiguity at call sites. +- Familiarity: users who know the RFC should recognize xphp and vice versa. + +## Considered Options + +- **Track the RFC syntax** — turbofish `Name::<...>` at call/`new` sites, bare + `<...>` at declarations and type-hint positions, `:` for bounds. +- **Invent a bespoke syntax** optimized purely for the transpiler. +- **Accept both bare and turbofish at call sites** for convenience. + +## Decision Outcome + +Chosen: **track the RFC syntax**. Declarations and type-hints use bare angle +brackets (`class Box`, `public T $item`); call and `new` sites require +turbofish with byte-level adjacency (`Box::`, `identity::(…)`); a +parenless `new Box;` form is rejected to avoid silent specialization. +The whitespace-sensitive lookahead means `Foo:: ` is *not* a turbofish. + +### Consequences + +- Good: `.xphp` source is a subset of the prospective native syntax — migration is + mechanical, not a rewrite. +- Good: no ambiguity with the `<` operator at call sites; the parser's intent is + unmistakable. +- Trade-off: the turbofish `::<>` is more verbose at call sites than bare `<>`, and + is a hard requirement (no dual-accept), which was a one-time rewrite of existing + source. + +### Confirmation + +Surface syntax is handled in the parser +([`XphpSourceParser`](../../src/Transpiler/Monomorphize/XphpSourceParser.php)); the +[Syntax tour](../syntax/index.md) documents every position, and the divergence +note lives in [the docs index](../index.md) and [comparison](../guides/comparison.md). + +## Pros and Cons of the Options + +### Track the RFC syntax + +- Good: forward-compatible; unambiguous; familiar to RFC readers. +- Bad: more verbose call sites; a hard switch with no deprecation window. + +### Bespoke syntax + +- Good: could be terser or transpiler-convenient. +- Bad: guarantees a future migration; unfamiliar; no shared mental model with PHP. + +### Dual-accept bare + turbofish + +- Good: lenient for authors. +- Bad: undermines the forward-compat promise (bare call sites won't run on a future + native runtime); two idioms confuse tooling and learners. + +## More Information + +- [PHP RFC: bound-erased generic types](https://wiki.php.net/rfc/bound_erased_generic_types). +- [Syntax tour](../syntax/index.md), [Type bounds](../syntax/type-bounds.md). diff --git a/docs/adr/0004-marker-interfaces-for-instanceof.md b/docs/adr/0004-marker-interfaces-for-instanceof.md new file mode 100644 index 0000000..0be5008 --- /dev/null +++ b/docs/adr/0004-marker-interfaces-for-instanceof.md @@ -0,0 +1,76 @@ +# 4. Marker interfaces for `instanceof` across specializations + +- Status: Accepted — 2026-05 + +## Context and Problem Statement + +Monomorphization turns `Box` and `Box` into two unrelated +classes with mangled, instantiation-specific names. But user code naturally +wants to ask "is this *a Box*, of whatever element?" — `$x instanceof Box`. After +specialization there is no longer a class literally named `Box` for that check to +target, and the two specializations share no common supertype. Something has to +preserve the template's identity at runtime. + +## Decision Drivers + +- `$x instanceof App\Containers\Box` should be true for *any* `Box<…>` + specialization, without the call site knowing the concrete arguments. +- The mechanism must be ordinary PHP (no runtime support code). +- It must not break PHP's autoload/LSP rules for the generated classes. + +## Considered Options + +- **Marker interface** — replace the template with an empty interface at its + original FQN; every specialization implements/extends it. +- **A shared abstract base class** the specializations extend. +- **Drop cross-specialization `instanceof`** and offer only concrete checks. + +## Decision Outcome + +Chosen: a **marker interface**. Each generic class/interface template is replaced +in the output by an empty interface at the template's original fully-qualified +name; every specialization `implements` (for classes) or `extends` (for +interfaces) that marker. So `App\Containers\Box` survives as a real interface, +and `$x instanceof App\Containers\Box` is true for every specialization. Generic +*traits* are removed entirely, since PHP cannot `instanceof` a trait. + +### Consequences + +- Good: cross-specialization `instanceof` works with plain PHP and zero runtime + code; the template name stays meaningful. +- Good: an interface (not a base class) keeps specializations free to have their + own real parents and avoids single-inheritance conflicts. +- Trade-off: the original template name becomes an empty interface in the output, + which can surprise someone reading the generated code without context. +- Trade-off: the variance subtype edges between specializations are a separate, + related concern (covariant/contravariant `extends` links). + +### Confirmation + +The replacement happens in the call-site rewriter +([`CallSiteRewriter`](../../src/Transpiler/Monomorphize/CallSiteRewriter.php)); the +specializer adds the `implements`/`extends` back-link. See +[Runtime semantics](../guides/runtime-semantics.md). + +## Pros and Cons of the Options + +### Marker interface + +- Good: any-arg `instanceof`; interface composes cleanly with existing parents. +- Bad: template name is an empty interface in output; traits can't participate. + +### Shared abstract base class + +- Good: also enables `instanceof`. +- Bad: consumes the single allowed parent class, conflicting with specializations + that already extend something. + +### Concrete-only `instanceof` + +- Good: nothing to generate. +- Bad: loses a natural, expected capability — you couldn't ask "is this a Box?". + +## More Information + +- [Variance](../syntax/variance.md) — the subtype edges between specializations. +- [ADR-0001](0001-monomorphization-over-type-erasure.md). diff --git a/docs/adr/0005-nominal-erased-bound-checking.md b/docs/adr/0005-nominal-erased-bound-checking.md new file mode 100644 index 0000000..33c445b --- /dev/null +++ b/docs/adr/0005-nominal-erased-bound-checking.md @@ -0,0 +1,90 @@ +# 5. Nominal, erased bound checking + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +Type-parameter bounds (`class Box`, intersections, unions, DNF, +F-bounded `T: Comparable`) need to be checked at compile time: does the +concrete argument satisfy the bound? Answering this requires a model of the type +hierarchy. But the compiler only sees the `.xphp` files it's given — a real +project also references vendor classes and hand-written `.php` classes the +compiler never parses. So for many concrete arguments the compiler genuinely +*cannot know* the answer. The design question is what to do with that ignorance. + +## Decision Drivers + +- Correctly accept satisfied bounds and reject violated ones for types the + compiler can see. +- Don't break valid code that references types outside the compiled source set. +- Keep the model simple and predictable. + +## Considered Options + +- **Nominal ancestor map with a three-valued result** — build a map of + declared-class → direct ancestors; `isSubtype` returns `true` / `false` / + `null` (unknown) and lets the caller decide what "unknown" means. +- **Reject the unknown case** (conservative) — anything unprovable fails. +- **Accept the unknown case** (permissive) — anything unprovable passes silently. +- **Require every referenced type to be in the source set.** + +## Decision Outcome + +Chosen: a **nominal ancestor map with a three-valued `isSubtype`**. A +`TypeHierarchy` records each declared class/interface/trait and its direct +ancestors (plus a small whitelist of common built-in interfaces); satisfaction is +a transitive walk over that map. Crucially `isSubtype` returns a *nullable* bool: +`true` (proven), `false` (disproven — the type is known and lacks the bound), or +`null` (the type isn't in the known set, so neither verdict is justified). Bound +combinators propagate the three values (intersection: any `false` → `false`, all +`true` → `true`, else unknown; union dually). Generic arguments on the bound +itself are erased for this check — comparison is by name. + +### Consequences + +- Good: bounds are enforced for everything the compiler can see, without breaking + projects that legitimately reference vendor / plain-`.php` types. +- Good: the three-valued result keeps the policy decision (what to do when unknown) + at the call site rather than baked into the hierarchy. +- Trade-off: an unprovable-but-actually-violating bound can slip through to a + runtime `TypeError` instead of a compile error. That's the cost of not requiring + a closed world; the value-flow gap is exactly what the PHPStan layer + ([ADR-0009](0009-phpstan-over-compiled-output.md)) is positioned to close. +- Trade-off: nominal erasure means bound-argument arity isn't checked — a deliberate + boundary, not an oversight. + +### Confirmation + +[`TypeHierarchy`](../../src/Transpiler/Monomorphize/TypeHierarchy.php) and the bound +combinators ([`BoundIntersection`](../../src/Transpiler/Monomorphize/BoundIntersection.php), +[`BoundUnion`](../../src/Transpiler/Monomorphize/BoundUnion.php)); see +[Type bounds](../syntax/type-bounds.md). + +## Pros and Cons of the Options + +### Nominal map, three-valued + +- Good: precise where it can be; honest where it can't; simple. +- Bad: lets unprovable violations reach runtime. + +### Reject unknown + +- Good: maximally strict. +- Bad: breaks every reference to a vendor / plain-PHP class — defeats progressive + enhancement. + +### Accept unknown + +- Good: never blocks valid code. +- Bad: silently misses real violations among unknown types. + +### Require all types in source + +- Good: a closed world makes every check decidable. +- Bad: incompatible with mixing `.xphp` and ordinary PHP — a non-starter. + +## More Information + +- [Type bounds](../syntax/type-bounds.md), [Caveats](../caveats.md). +- [ADR-0009](0009-phpstan-over-compiled-output.md) closes the value-flow gap with a + full type checker. diff --git a/docs/adr/0006-bounded-specialization-depth-cap.md b/docs/adr/0006-bounded-specialization-depth-cap.md new file mode 100644 index 0000000..40330fc --- /dev/null +++ b/docs/adr/0006-bounded-specialization-depth-cap.md @@ -0,0 +1,76 @@ +# 6. Bounded specialization with a hard depth cap + +- Status: Accepted — 2026-05 + +## Context and Problem Statement + +Specialization is a fixed-point process: specializing `wrap(): Box` for +`int` introduces a new `Box` instantiation, which must itself be +specialized, which may introduce more. For ordinary code this converges quickly. +But a self-referential or combinatorial template (e.g. one whose specialization +keeps producing a strictly more-nested instantiation) can drive the loop forever. +The compiler needs a guaranteed-terminating story. + +## Decision Drivers + +- The compiler must always terminate, even on pathological or malicious input. +- A real bug or runaway should fail loudly and quickly, not hang. +- Don't penalize legitimate (finite, possibly deep) generic code. + +## Considered Options + +- **A hard depth cap that aborts** the whole run when nesting exceeds a fixed + limit. +- **No cap**, trusting templates to converge. +- **Report the cap as a collectable diagnostic** and keep going. + +## Decision Outcome + +Chosen: a **hard depth cap that aborts**. The specialization loop tracks nesting +depth and aborts the entire compile/check run with a clear message once it +exceeds a fixed limit chosen to be far beyond any realistic generic nesting. This +is treated as a runaway-input guard — a distinct error class from user-facing +generic errors like a bound violation. + +### Consequences + +- Good: termination is guaranteed; a spiraling template fails fast with an + actionable message instead of hanging. +- Good: the limit is generous enough that ordinary deep generics never hit it. +- Trade-off: a genuinely legitimate but extremely deep instantiation would be + refused; the workaround is to break it into intermediate templates. +- Notable: unlike most checks, the depth cap is **not** routed through the + collect-or-throw diagnostic seam + ([ADR-0008](0008-collect-or-throw-diagnostic-seam.md)). If it were merely + collected and the loop continued, the next iteration would hit the cap again + forever — so it must abort. + +### Confirmation + +The cap lives in the fixed-point loop in +[`Compiler::compile()`](../../src/Transpiler/Monomorphize/Compiler.php); a fixture +exercises a self-referential template that trips it. + +## Pros and Cons of the Options + +### Hard cap, abort + +- Good: guaranteed termination; fast, clear failure. +- Bad: a (rare) legitimate very-deep template is refused. + +### No cap + +- Good: never refuses anything. +- Bad: a single bad template can hang the compiler indefinitely. + +### Collect-and-continue + +- Good: consistent with the other checks' reporting model. +- Bad: doesn't actually stop the loop — it would re-trip endlessly; a contradiction + for a non-terminating condition. + +## More Information + +- [ADR-0001](0001-monomorphization-over-type-erasure.md) — why a specialization + loop exists at all. +- [Caveats](../caveats.md). diff --git a/docs/adr/0007-xphp-check-gate.md b/docs/adr/0007-xphp-check-gate.md new file mode 100644 index 0000000..733097a --- /dev/null +++ b/docs/adr/0007-xphp-check-gate.md @@ -0,0 +1,81 @@ +# 7. The `xphp check` validate-only gate + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +The only way to validate generic code used to be to `compile` it. Compilation +threw on the *first* generic error, as a bare exception with no `file:line`, and +produced output as a side effect. For CI and editors that's a poor fit: you fix +one error, recompile, find the next, repeat; there's no machine-readable result +and no way to "just check" without emitting `dist/`. xphp needs a first-class +validation entry point. + +## Decision Drivers + +- Report *all* problems in one run, each with a precise `file:line`. +- No side effects — a pure gate that writes nothing. +- Machine-readable, CI- and IDE-friendly output and exit codes. +- Resilience: one unparseable file shouldn't blind the check to the rest. + +## Considered Options + +- **A dedicated `xphp check` command** — validate-only, collect-all, structured + diagnostics, multiple renderers, structured exit codes. +- **Keep `compile` fail-fast, add a `--collect-errors` flag** to it. +- **Emit a diagnostics JSON file** from `compile` and let CI parse it. + +## Decision Outcome + +Chosen: a **dedicated `xphp check` command**. It runs the validation phases +without specializing or emitting anything, gathers every diagnostic in a single +run (each a structured record with a stable code, severity, and `file:line`), +renders them as `text`, `json`, or `github` (PR annotations), and returns exit +**0** (clean), **1** (≥1 error), or **2** (operational failure — bad source dir +or unknown format). Each file is parsed in isolation, so a syntax error in one is +reported and the rest are still checked. + +### Consequences + +- Good: one CI step surfaces every problem at once, with locations; the `github` + renderer puts findings inline on PRs; the `json` renderer feeds tooling/IDEs. +- Good: stable diagnostic codes (e.g. `xphp.bound_violation`) give a contract for + tooling and a searchable [errors reference](../errors.md). +- Good: it's the natural place to add more analyses behind one gate — see + [ADR-0009](0009-phpstan-over-compiled-output.md). +- Trade-off: there are now two entry points (`compile` and `check`) whose + validation must agree; that's exactly what the shared seam in + [ADR-0008](0008-collect-or-throw-diagnostic-seam.md) guarantees. + +### Confirmation + +[`CheckCommand`](../../src/Console/Command/CheckCommand.php) and the validate-only +path [`Compiler::check()`](../../src/Transpiler/Monomorphize/Compiler.php); the +renderers in [`src/Diagnostics/Renderer/`](../../src/Diagnostics/Renderer/). The +[errors reference](../errors.md) documents the codes, formats, and exit codes. + +## Pros and Cons of the Options + +### Dedicated `check` command + +- Good: collect-all; no side effects; structured codes/formats/exit codes; per-file + resilience; extensible. +- Bad: a second entry point to keep behaviorally consistent with `compile`. + +### `compile --collect-errors` + +- Good: one command. +- Bad: still emits output; conflates "validate" with "build"; risks forking the + validator logic. + +### Emit a JSON file + +- Good: machine-readable. +- Bad: pushes parsing/format burden to every CI; no inline PR annotations; awkward + for interactive use. + +## More Information + +- [Errors and diagnostics](../errors.md). +- [ADR-0008](0008-collect-or-throw-diagnostic-seam.md) — how `check` and `compile` + share one validator. diff --git a/docs/adr/0008-collect-or-throw-diagnostic-seam.md b/docs/adr/0008-collect-or-throw-diagnostic-seam.md new file mode 100644 index 0000000..86328c4 --- /dev/null +++ b/docs/adr/0008-collect-or-throw-diagnostic-seam.md @@ -0,0 +1,81 @@ +# 8. The collect-or-throw diagnostic seam + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +`xphp check` ([ADR-0007](0007-xphp-check-gate.md)) needs to *collect* every +validation error and keep going; `xphp compile` needs to *throw* on the first +one and stay byte-identical to its long-standing behavior (a large body of tests +pins the exact exception messages, down to substrings like `(position 1)`). Both +must run the *same* validation logic — duplicating it would guarantee drift +between what `check` reports and what `compile` enforces. How should one set of +validators serve both modes? + +## Decision Drivers + +- A single source of truth for validation and for each error message. +- `compile` stays fail-fast and byte-identical (no message drift). +- Minimal, low-ceremony change — no heavyweight abstraction threaded everywhere. + +## Considered Options + +- **An optional trailing `?DiagnosticCollector` parameter** on the validation + methods: absent ⇒ throw (as before); present ⇒ append a diagnostic and continue. +- **A `DiagnosticSink` interface** with `Throwing` and `Collecting` implementations + injected through the stack. +- **Fork the validators** into separate compile and check code paths. + +## Decision Outcome + +Chosen: an **optional `?DiagnosticCollector` parameter**. Validation methods take +a nullable collector as their last argument. When it's absent (the `compile` +path), they throw exactly as before. When it's present (the `check` path), each +violation is appended as a structured diagnostic and validation continues, so all +problems in a phase surface together. The user-facing text comes from a single +shared message builder used by both the `throw` and the diagnostic, so the two +can never diverge. The collector is mutable by design — it's the one sink threaded +through the validation phase. + +### Consequences + +- Good: one validator, one message string, two behaviors; `compile` output is + provably byte-identical and `check` collects — verified by tests that assert both + from the same input. +- Good: tiny surface area — a nullable parameter, no interface hierarchy or DI. +- Trade-off: "collect-all" means *all errors of the earliest failing phase* — + validation still halts between phases (e.g. a duplicate-definition stops the run + before instantiation-site checks), so it isn't a single flat list of everything. +- Trade-off: the no-collector default must be preserved at every call site, or that + site silently reverts to fail-fast; this is covered by tests. + +### Confirmation + +The seam threads through the [`Registry`](../../src/Transpiler/Monomorphize/Registry.php) +and the validators; the diagnostic model is in +[`src/Diagnostics/`](../../src/Diagnostics/DiagnosticCollector.php). Tests assert +both the byte-identical throw path and the collect path for the same fixtures. + +## Pros and Cons of the Options + +### Optional `?DiagnosticCollector` parameter + +- Good: one code path; shared message; minimal ceremony; byte-identical compile. +- Bad: a per-call-site convention to uphold; collection is per-phase, not global. + +### `DiagnosticSink` interface + +- Good: clean polymorphism. +- Bad: two implementations + injection through the stack for only two call modes; + overkill. + +### Forked validators + +- Good: each path is self-contained. +- Bad: duplicated logic; near-certain message drift between check and compile. + +## More Information + +- [ADR-0007](0007-xphp-check-gate.md) — the gate this enables. +- [ADR-0009](0009-phpstan-over-compiled-output.md) and + [ADR-0010](0010-undeclared-type-and-arity-validation.md) reuse the same seam. diff --git a/docs/adr/0009-phpstan-over-compiled-output.md b/docs/adr/0009-phpstan-over-compiled-output.md new file mode 100644 index 0000000..e86fb26 --- /dev/null +++ b/docs/adr/0009-phpstan-over-compiled-output.md @@ -0,0 +1,107 @@ +# 9. PHPStan over the compiled output + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +xphp's own generic checks are nominal and erased +([ADR-0005](0005-nominal-erased-bound-checking.md)) — they validate that +instantiations are well-formed, but they don't analyze the *bodies* of generic +code for value-flow type errors. A method that returns the wrong type inside a +`Box` is invisible to xphp's checks. PHPStan already does exactly that kind of +analysis — but it can't read `.xphp`. Since monomorphization produces concrete +PHP ([ADR-0002](0002-build-time-transpiler.md)), there *is* something PHPStan can +analyze. The question is how to wire a real type-checker over the compiled output +without reinventing it or fighting the user's existing setup. + +## Decision Drivers + +- Reuse PHPStan's analysis; don't reimplement value-flow type checking. +- Respect the consumer's existing PHPStan configuration (their level, rules, + extensions, ignores) — one config, not a competing one. +- Map findings back to the `.xphp` the author wrote, not the generated files. +- Keep PHPStan optional and the xphp distribution lean. + +## Considered Options + +- **Run the consumer's PHPStan over compiled output, one representative + specialization per template, behind the same gate.** +- **Ship a fixed, xphp-owned PHPStan config.** +- **Bundle PHPStan as a hard dependency** so analysis always runs. +- **Analyze every specialization** of every template. + +## Decision Outcome + +Chosen: **run the consumer's own PHPStan over the compiled output**, integrated +into `xphp check`. When the generic checks pass, xphp compiles to a throwaway +directory and invokes PHPStan over **one representative specialization per +template** (chosen deterministically), driven by the **consumer's own config** +(auto-detected or pointed at with a flag) via an ephemeral config that `includes:` +it by absolute path. Findings are mapped from the generated file back to the +originating template's `.xphp` declaration line, naming the instantiation that +surfaced them. PHPStan stays a dev-only tool: never bundled in the PHAR, resolved +at runtime, and a **non-failing Warning** if absent — the generic gate still +delivers value on its own. + +One representative per template is sound because a body type error erases to +nominal types and therefore manifests identically across every specialization of +that template; analyzing one surfaces the bug once instead of N noisy times. This +also removed an earlier message-normalization de-duplication heuristic. + +### Consequences + +- Good: real value-flow analysis with zero reimplementation; one config and one CI + gate cover both generic correctness and body type safety. +- Good: findings point at the author's source; "one representative" keeps the + report free of duplicate findings and is deterministic for stable CI output. +- Good: lean distribution — PHPStan isn't shipped, and a missing/failed PHPStan + degrades to a Warning rather than breaking the build. +- Trade-off: a body error that only manifests for *specific* concrete arguments may + be missed — that's a value-flow bug PHPStan can't attribute to a template line + anyway. +- Trade-off: a consumer's path-based PHPStan baseline won't match the generated + paths (identifier/message ignores still work); a config that leans on `%rootDir%` + resolves it against the ephemeral config's location. Documented limitations. + +### Confirmation + +The pieces live in [`src/StaticAnalysis/`](../../src/StaticAnalysis/StaticAnalysisGate.php) +— workspace compile, representative selection, the PHPStan runner, and the result +mapper that re-anchors findings to `.xphp`. PHPStan is a dev-only dependency and is +stripped from the PHAR build. See the [errors reference](../errors.md) for the +`phpstan.*` diagnostic codes and the `--no-phpstan` / `--phpstan-bin` / +`--phpstan-config` options. + +## Pros and Cons of the Options + +### Consumer's PHPStan, one representative + +- Good: reuses PHPStan; respects the consumer's rules; one gate; deterministic, + de-duplicated findings mapped to source. +- Bad: per-argument-only errors can be missed; path-based baselines/`%rootDir%` + have caveats. + +### Fixed xphp-owned config + +- Good: simplest to package. +- Bad: two rule sets for one codebase; the consumer can't tune what runs over their + `.xphp`. + +### Bundle PHPStan as a hard dependency + +- Good: "always works". +- Bad: bloats the PHAR; couples xphp's release to a PHPStan version; users can't + upgrade independently. + +### Analyze every specialization + +- Good: maximal coverage. +- Bad: N duplicate findings per template; needs a fragile message-dedup heuristic; + slower. + +## More Information + +- [Errors and diagnostics](../errors.md) — `phpstan.*` codes and the CLI options. +- [ADR-0005](0005-nominal-erased-bound-checking.md) (the gap this closes), + [ADR-0007](0007-xphp-check-gate.md) and + [ADR-0008](0008-collect-or-throw-diagnostic-seam.md) (the gate and seam it builds on). diff --git a/docs/adr/0010-undeclared-type-and-arity-validation.md b/docs/adr/0010-undeclared-type-and-arity-validation.md new file mode 100644 index 0000000..22ce560 --- /dev/null +++ b/docs/adr/0010-undeclared-type-and-arity-validation.md @@ -0,0 +1,91 @@ +# 10. Undeclared-type and arity validation + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +Two authoring mistakes used to pass silently. A generic member that names a type +parameter the template never declared — `interface Foo { add(T $x); }`, where +`T` is a typo for `Z` — compiled to a reference to a non-existent class (`\App\T`) +with no error. And instantiating with more type arguments than declared — +`Box::` for a one-parameter `Box` — silently dropped the extras. +Both produce wrong output from plainly-wrong input. The hard part of the first is +that, in erased nominal terms, a stray `T` is indistinguishable from a reference +to a real class named `T` — xphp has no whole-program symbol table to tell them +apart. + +## Decision Drivers + +- Catch these mistakes at `check`/`compile` time, not at runtime. +- Don't reject valid code, especially references to real types. +- Keep it inside xphp's erased model (no full type resolution — that's PHPStan's + job, [ADR-0009](0009-phpstan-over-compiled-output.md)). + +## Considered Options + +For undeclared-type detection: +- **A broad structural rule** — flag any bare, unqualified, single-segment type + name used inside a generic context that is neither a declared type parameter, + a built-in, nor imported, and that resolves to no type the compiler can see. +- **A narrow heuristic** — only flag single-letter names. +- **Defer entirely to PHPStan.** + +For arity: **report over-arity** vs **keep truncating silently**. + +## Decision Outcome + +Chosen: the **broad structural rule**, plus **reporting over-arity**. A bare, +unqualified, single-segment name inside a generic context that isn't a declared +parameter, a scalar/built-in, or brought in by a `use` import — and that resolves +to nothing the compiler knows — is reported as `xphp.undeclared_type`. Fully +qualified names and `use`-imported names are the escape hatch and are never +flagged. Supplying more type arguments than a template declares is reported as +`xphp.too_many_type_arguments` instead of being truncated. Both reuse the +collect-or-throw seam ([ADR-0008](0008-collect-or-throw-diagnostic-seam.md)), so +they fail `compile` and are collected by `check`. + +### Consequences + +- Good: the common typo and the over-arity mistake now fail fast with a clear + message and `file:line`, instead of emitting broken or silently-wrong code. +- Good: a dry-run of the broad rule over the entire existing test corpus produced + zero false positives on valid code. +- Trade-off (accepted): a real class in the *same namespace* that lives in a plain + `.php` file (which `check` doesn't scan) and is referenced **without** a `use` + will be flagged, because the compiler can't see it and it looks like a stray + parameter. The remedy is to `use`/fully-qualify it — both silence the check. The + alternative (a narrow single-letter heuristic) would miss real multi-letter + typos, so the broad rule with a documented escape hatch was preferred. + +### Confirmation + +The detection runs as a validation phase +([`UndeclaredTypeParameterValidator`](../../src/Transpiler/Monomorphize/UndeclaredTypeParameterValidator.php)); +the arity check lives in the registry's argument padding. Both `xphp.undeclared_type` +and `xphp.too_many_type_arguments` are in the [errors reference](../errors.md). + +## Pros and Cons of the Options + +### Broad structural rule + +- Good: catches real typos in members, bounds, and defaults; zero false positives + on the existing corpus; cheap and resolution-free. +- Bad: the accepted false positive above (same-namespace plain-`.php` class, no + `use`). + +### Narrow single-letter heuristic + +- Good: even lower false-positive risk. +- Bad: misses multi-letter undeclared names; users have to learn the heuristic. + +### Defer to PHPStan + +- Good: full name resolution. +- Bad: PHPStan runs on generated PHP, can't see the generic context, and isn't + always installed; the mistake should fail the fast, resolution-free gate too. + +## More Information + +- [Errors and diagnostics](../errors.md). +- [ADR-0009](0009-phpstan-over-compiled-output.md) — the resolution-aware layer this + intentionally stops short of. diff --git a/docs/adr/0011-phar-distribution.md b/docs/adr/0011-phar-distribution.md new file mode 100644 index 0000000..09c43bc --- /dev/null +++ b/docs/adr/0011-phar-distribution.md @@ -0,0 +1,78 @@ +# 11. PHAR distribution via Humbug Box + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +xphp is a build-time CLI ([ADR-0002](0002-build-time-transpiler.md)). Composer +users get `vendor/bin/xphp` for free, but plenty of places that want to run it +aren't Composer projects: CI scripts, Docker images, ops tooling, a quick +one-off. They need a way to run the compiler with stock PHP and nothing else. And +when xphp *is* installed as a Composer dependency, `bin/xphp` must find the right +autoloader regardless of how it was installed. + +## Decision Drivers + +- A single self-contained artifact runnable with stock PHP, no Composer. +- Don't ship development-only dependencies (notably PHPStan) to end users. +- Verifiable, reproducible release artifacts. +- `vendor/bin/xphp` must work whether xphp is the root project or a dependency. + +## Considered Options + +- **A single PHAR built with Humbug Box**, published on every release tag with a + checksum; PHPStan stripped via a no-dev install. +- **Distribution only via Composer.** +- **A separate, hand-assembled binary repository.** + +## Decision Outcome + +Chosen: a **PHAR built with Humbug Box**, attached to every `v*` release tag +alongside a SHA-256 sum. The build does a `composer install --no-dev` before +packaging so development-only dependencies (PHPStan) are excluded, then restores +the dev install. The runtime dependency needed for the optional PHPStan pass +(`symfony/process`) *is* included, since the runner ships in the PHAR even though +PHPStan itself does not. Separately, `bin/xphp` probes for the Composer autoloader +in priority order — the Composer bin-proxy global, then the as-a-dependency path, +then the standalone path — so it works installed either way. + +### Consequences + +- Good: `curl` the PHAR and run it with stock PHP — no Composer, no install dance; + CI and Docker get a one-file tool. +- Good: PHPStan is never shipped to users; they install it themselves and upgrade it + independently of xphp's release cycle (see + [ADR-0009](0009-phpstan-over-compiled-output.md)). +- Good: the published checksum lets consumers verify the download. +- Trade-off: a second distribution channel (PHAR + Composer) to build and test; the + Box version is pinned so a new Box release can't silently change the artifact. + +### Confirmation + +[`box.json`](../../box.json) and the `build/phar` target in the +[`Makefile`](../../Makefile) (the `--no-dev` package-then-restore); the autoloader +probing in [`bin/xphp`](../../bin/xphp). The release workflow builds the PHAR and a +SHA-256 sum on every `v*` tag. + +## Pros and Cons of the Options + +### PHAR via Humbug Box + +- Good: self-contained; stock-PHP runnable; dev deps stripped; checksum-verifiable. +- Bad: a second channel to maintain; depends on a pinned external packaging tool. + +### Composer-only + +- Good: nothing extra to build. +- Bad: excludes every non-Composer consumer (CI/Docker/ops/one-offs). + +### Hand-assembled binary repo + +- Good: full control over contents. +- Bad: manual, error-prone, and duplicative of what Box automates. + +## More Information + +- [Getting started](../getting-started.md). +- [ADR-0009](0009-phpstan-over-compiled-output.md) — why PHPStan is dev-only and the + process dependency ships. diff --git a/docs/adr/0012-engineering-quality-bar.md b/docs/adr/0012-engineering-quality-bar.md new file mode 100644 index 0000000..0c6db24 --- /dev/null +++ b/docs/adr/0012-engineering-quality-bar.md @@ -0,0 +1,82 @@ +# 12. The engineering quality bar + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +A generics compiler is a correctness-critical tool: a bug doesn't just misbehave, +it emits wrong code into someone else's project. That argues for an unusually high +bar on the compiler's own codebase and its tests. But strictness has costs — +slower CI, more findings to triage, more test ceremony — so the bar has to be a +deliberate, documented choice rather than an accident. + +## Decision Drivers + +- High confidence that the compiler itself is correct. +- Tests that actually fail when behavior regresses (not just line coverage). +- Stable, modern target platform without blocking on bleeding-edge syntax. +- Fast, deterministic CI signal. + +## Considered Options + +- **A high, enforced bar**: PHPStan at the strictest level on `src/`, Infection + mutation testing gated on a high MSI, fixture+snapshot end-to-end tests, a fixed + minimum PHP with newer-syntax tests isolated into their own group/runtime. +- **A conventional bar**: a mid PHPStan level, line-coverage thresholds, ad-hoc + integration tests. + +## Decision Outcome + +Chosen: the **high, enforced bar**, adopted progressively. + +- **PHPStan at the strictest level** over `src/`, reached by stepping the level up + and clearing findings at each stop rather than baselining them away. +- **Mutation testing (Infection)** gated on a high MSI; every surviving mutant is + either killed with a new test or annotated inline as a genuinely-equivalent + mutant with a one-line reason, so the report only ever shows real gaps. +- **Fixture + snapshot tests**: a feature compiles a `.xphp` source tree and its + emitted PHP is snapshotted; deterministic generated-name hashes are normalized to + stable placeholders so a role-swap regression still shows up as a diff. +- **A fixed minimum PHP** (the supported runtime) with tests that exercise + newer-PHP syntax isolated into their own group, run on a dedicated runtime and + skipped elsewhere — so the default suite stays fast and the production parser + stays on the supported version. + +### Consequences + +- Good: high confidence in the compiler; tests bite on real regressions; CI signal + is fast (heavy/optional suites are split out) and the analysis target is stable. +- Good: the discipline is self-documenting — equivalent-mutant annotations and the + curated ignore set explain *why* something isn't tested, instead of hiding it. +- Trade-off: stricter analysis and mutation runs are slower and demand more effort + per change; newer-syntax features must wait for the dedicated runtime to cover + them. + +### Confirmation + +The PHPStan configuration, the Infection configuration with its curated +equivalent-mutant ignore set, the snapshot/fixture test support, and the CI +workflow split (default suite, newer-syntax group, the optional analysis-pass +tests, and the mutation job) collectively enforce this. See +[CONTRIBUTING](../../CONTRIBUTING.md) for how to run each locally. + +## Pros and Cons of the Options + +### High, enforced bar + +- Good: maximal confidence; regression-sensitive tests; fast, deterministic CI; + self-documenting discipline. +- Bad: slower analysis/mutation; more upfront effort; newer syntax gated on a + dedicated runtime. + +### Conventional bar + +- Good: cheaper and faster to satisfy. +- Bad: line coverage hides untested logic; a mid analysis level lets subtler bugs + through — unacceptable risk for a code generator. + +## More Information + +- [CONTRIBUTING](../../CONTRIBUTING.md) — the test/lint targets and the group split. +- [ADR-0001](0001-monomorphization-over-type-erasure.md) — why correctness of the + generated code matters so much. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..77844a7 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,32 @@ +# Architecture Decision Records + +This directory records the **architecturally significant** decisions behind +xphp — the choices that are expensive to reverse and that shape everything +built on top of them: how generics are implemented, what the compiler emits, +how the `check` gate and the PHPStan layer work, and the quality bar the +codebase holds itself to. + +Each record states the problem, the options that were on the table, the option +chosen and why, and the consequences (the good and the trade-offs). They are +written after the fact, from the project's history, so they read as a map of +*why xphp is shaped the way it is* — useful whether you're evaluating xphp, +contributing to it, or just curious. + +We use the [MADR](https://adr.github.io/madr/) format. New significant decisions +should be added here as a new numbered file; copy +[`0000-adr-template.md`](0000-adr-template.md) to start. + +| # | Decision | Status | +|---|----------|--------| +| [0001](0001-monomorphization-over-type-erasure.md) | Monomorphization over type erasure | Accepted | +| [0002](0002-build-time-transpiler.md) | A build-time transpiler that emits plain PHP | Accepted | +| [0003](0003-rfc-aligned-turbofish-syntax.md) | RFC-aligned turbofish surface syntax | Accepted | +| [0004](0004-marker-interfaces-for-instanceof.md) | Marker interfaces for `instanceof` across specializations | Accepted | +| [0005](0005-nominal-erased-bound-checking.md) | Nominal, erased bound checking | Accepted | +| [0006](0006-bounded-specialization-depth-cap.md) | Bounded specialization with a hard depth cap | Accepted | +| [0007](0007-xphp-check-gate.md) | The `xphp check` validate-only gate | Accepted | +| [0008](0008-collect-or-throw-diagnostic-seam.md) | The collect-or-throw diagnostic seam | Accepted | +| [0009](0009-phpstan-over-compiled-output.md) | PHPStan over the compiled output | Accepted | +| [0010](0010-undeclared-type-and-arity-validation.md) | Undeclared-type and arity validation | Accepted | +| [0011](0011-phar-distribution.md) | PHAR distribution via Humbug Box | Accepted | +| [0012](0012-engineering-quality-bar.md) | The engineering quality bar | Accepted | diff --git a/docs/index.md b/docs/index.md index 9c2847f..367f4c3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -35,6 +35,7 @@ echo $intContainer->get(), $strContainer->get(); // 42hello | [Runtime semantics](guides/runtime-semantics.md) | What the generated code looks like and why reflection/serializers behave the way they do. | | [Comparison](guides/comparison.md) | TS / Kotlin / Rust experience — what carries over and what's different. | | [Roadmap](roadmap.md) | What's shipped, what's queued. Generics are the start. | +| [Architecture decisions](adr/README.md) | Why xphp is shaped the way it is — the significant design choices and their trade-offs. | ## Heads up — divergence from the RFC From 263dd2d07d82818360400539df63b7396d51696c Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 18 Jun 2026 18:51:56 +0000 Subject: [PATCH 030/114] =?UTF-8?q?docs(adr):=20correct=20ADR-0005=20?= =?UTF-8?q?=E2=80=94=20unknown=20bounds=20are=20rejected,=20not=20deferred?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../adr/0005-nominal-erased-bound-checking.md | 71 ++++++++++++------- 1 file changed, 47 insertions(+), 24 deletions(-) diff --git a/docs/adr/0005-nominal-erased-bound-checking.md b/docs/adr/0005-nominal-erased-bound-checking.md index 33c445b..a52afa3 100644 --- a/docs/adr/0005-nominal-erased-bound-checking.md +++ b/docs/adr/0005-nominal-erased-bound-checking.md @@ -16,17 +16,25 @@ compiler never parses. So for many concrete arguments the compiler genuinely - Correctly accept satisfied bounds and reject violated ones for types the compiler can see. -- Don't break valid code that references types outside the compiled source set. +- Distinguish a *definite* violation from "can't tell", so the error message is + accurate and actionable rather than misleading. - Keep the model simple and predictable. ## Considered Options -- **Nominal ancestor map with a three-valued result** — build a map of - declared-class → direct ancestors; `isSubtype` returns `true` / `false` / - `null` (unknown) and lets the caller decide what "unknown" means. -- **Reject the unknown case** (conservative) — anything unprovable fails. +The mechanism for modelling the hierarchy, and the policy for the "can't tell" +case, are really one choice: + +- **A nominal ancestor map with a three-valued result, rejecting anything not + proven** — build a map of declared-class → direct ancestors; `isSubtype` + returns `true` / `false` / `null` (unknown); the bound check passes only on + `true` and rejects both `false` and `null`, using the distinction to emit a + different message for each. +- **A two-valued check** that collapses unknown into `false` — also rejects + unprovable types, but can't tell the user *why* (everything is "does not + implement"). - **Accept the unknown case** (permissive) — anything unprovable passes silently. -- **Require every referenced type to be in the source set.** +- **Require every referenced type to be in the source set** (a closed world). ## Decision Outcome @@ -40,16 +48,27 @@ combinators propagate the three values (intersection: any `false` → `false`, a `true` → `true`, else unknown; union dually). Generic arguments on the bound itself are erased for this check — comparison is by name. +The bound check then passes **only on `true`**. A `false` is reported as a +definite violation (*"X does not extend/implement the bound"*); a `null` is also +reported, but with a distinct, actionable message (*"X is not in the analysed +source set, so the compiler cannot prove it satisfies the bound — add it to the +sources, or relax the bound"*). So the policy on "can't tell" is **conservative +rejection**, and the three-valued result exists precisely so that rejection can +be explained accurately instead of being conflated with a real violation. + ### Consequences -- Good: bounds are enforced for everything the compiler can see, without breaking - projects that legitimately reference vendor / plain-`.php` types. -- Good: the three-valued result keeps the policy decision (what to do when unknown) - at the call site rather than baked into the hierarchy. -- Trade-off: an unprovable-but-actually-violating bound can slip through to a - runtime `TypeError` instead of a compile error. That's the cost of not requiring - a closed world; the value-flow gap is exactly what the PHPStan layer - ([ADR-0009](0009-phpstan-over-compiled-output.md)) is positioned to close. +- Good: bounds are enforced for everything the compiler can see; an unprovable + type produces a clear, distinct error at `check`/`compile` time — never a silent + pass and never a misleading "does not implement" for a type the compiler simply + couldn't see. +- Good: the three-valued result keeps the policy (what to do when unknown) at the + call site rather than baked into the hierarchy. +- Trade-off: the policy is conservative — a vendor or plain-`.php` type that *would* + satisfy the bound at runtime is still rejected when the compiler can't see it, + because it can't be proven. The remedy is to include that type in the analysed + sources or to widen/drop the bound. (Same closed-world limitation as the + undeclared-type check in [ADR-0010](0010-undeclared-type-and-arity-validation.md).) - Trade-off: nominal erasure means bound-argument arity isn't checked — a deliberate boundary, not an oversight. @@ -62,21 +81,23 @@ combinators ([`BoundIntersection`](../../src/Transpiler/Monomorphize/BoundInters ## Pros and Cons of the Options -### Nominal map, three-valued +### Nominal map, three-valued, reject-unless-proven -- Good: precise where it can be; honest where it can't; simple. -- Bad: lets unprovable violations reach runtime. +- Good: precise where it can be; rejects the unknown case with an accurate, + actionable message; simple. +- Bad: conservative — rejects vendor / plain-`.php` types it can't see, even valid + ones (remedy: add them to the sources, or relax the bound). -### Reject unknown +### Two-valued (collapse unknown into `false`) -- Good: maximally strict. -- Bad: breaks every reference to a vendor / plain-PHP class — defeats progressive - enhancement. +- Good: also rejects unprovable types; even simpler. +- Bad: can't distinguish "definitely violates" from "can't tell", so the user gets + a misleading "does not implement" for a type the compiler merely couldn't see. ### Accept unknown - Good: never blocks valid code. -- Bad: silently misses real violations among unknown types. +- Bad: silently misses real violations among unknown types — unsound. ### Require all types in source @@ -86,5 +107,7 @@ combinators ([`BoundIntersection`](../../src/Transpiler/Monomorphize/BoundInters ## More Information - [Type bounds](../syntax/type-bounds.md), [Caveats](../caveats.md). -- [ADR-0009](0009-phpstan-over-compiled-output.md) closes the value-flow gap with a - full type checker. +- This check covers *bound satisfaction* only. Value-flow type errors *inside* + generic bodies are a separate concern handled by + [ADR-0009](0009-phpstan-over-compiled-output.md) (PHPStan over the compiled + output), not by this hierarchy. From 0c6b640e341eaf3cb89c2d15290a70e2726806a3 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 18 Jun 2026 18:59:35 +0000 Subject: [PATCH 031/114] docs(adr): correct three records to match the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../adr/0005-nominal-erased-bound-checking.md | 13 +++++++--- .../0006-bounded-specialization-depth-cap.md | 21 +++++++++------- .../0008-collect-or-throw-diagnostic-seam.md | 25 +++++++++++-------- 3 files changed, 37 insertions(+), 22 deletions(-) diff --git a/docs/adr/0005-nominal-erased-bound-checking.md b/docs/adr/0005-nominal-erased-bound-checking.md index a52afa3..52e4445 100644 --- a/docs/adr/0005-nominal-erased-bound-checking.md +++ b/docs/adr/0005-nominal-erased-bound-checking.md @@ -74,9 +74,16 @@ be explained accurately instead of being conflated with a real violation. ### Confirmation -[`TypeHierarchy`](../../src/Transpiler/Monomorphize/TypeHierarchy.php) and the bound -combinators ([`BoundIntersection`](../../src/Transpiler/Monomorphize/BoundIntersection.php), -[`BoundUnion`](../../src/Transpiler/Monomorphize/BoundUnion.php)); see +The three-valued subtype test is +[`TypeHierarchy::isSubtype`](../../src/Transpiler/Monomorphize/TypeHierarchy.php); +the three-valued folding over a compound bound is `Registry::evaluateBound`, and +the reject-unless-`true` policy (with the distinct messages) is in +`Registry::checkBounds` +([`Registry`](../../src/Transpiler/Monomorphize/Registry.php)). The bound AST node +types it folds over are +[`BoundLeaf`](../../src/Transpiler/Monomorphize/BoundLeaf.php), +[`BoundIntersection`](../../src/Transpiler/Monomorphize/BoundIntersection.php), and +[`BoundUnion`](../../src/Transpiler/Monomorphize/BoundUnion.php). See [Type bounds](../syntax/type-bounds.md). ## Pros and Cons of the Options diff --git a/docs/adr/0006-bounded-specialization-depth-cap.md b/docs/adr/0006-bounded-specialization-depth-cap.md index 40330fc..7b73919 100644 --- a/docs/adr/0006-bounded-specialization-depth-cap.md +++ b/docs/adr/0006-bounded-specialization-depth-cap.md @@ -26,17 +26,20 @@ The compiler needs a guaranteed-terminating story. ## Decision Outcome -Chosen: a **hard depth cap that aborts**. The specialization loop tracks nesting -depth and aborts the entire compile/check run with a clear message once it -exceeds a fixed limit chosen to be far beyond any realistic generic nesting. This -is treated as a runaway-input guard — a distinct error class from user-facing -generic errors like a bound violation. +Chosen: a **hard depth cap that aborts**. The fixed-point specialization loop +tracks nesting depth and aborts the `compile` run with a clear message once it +exceeds a fixed limit (16 levels of nested specialization, +`Compiler::MAX_SPECIALIZATION_DEPTH`) set well above realistic generic nesting. +This is treated as a runaway-input guard — a distinct error class from +user-facing generic errors like a bound violation. The cap applies to `compile` +only: `check` validates without ever specializing, so it never enters the loop. ### Consequences - Good: termination is guaranteed; a spiraling template fails fast with an actionable message instead of hanging. -- Good: the limit is generous enough that ordinary deep generics never hit it. +- Good: the limit sits well above realistic nesting, so ordinary deep generics + don't hit it. - Trade-off: a genuinely legitimate but extremely deep instantiation would be refused; the workaround is to break it into intermediate templates. - Notable: unlike most checks, the depth cap is **not** routed through the @@ -47,9 +50,9 @@ generic errors like a bound violation. ### Confirmation -The cap lives in the fixed-point loop in -[`Compiler::compile()`](../../src/Transpiler/Monomorphize/Compiler.php); a fixture -exercises a self-referential template that trips it. +The cap is `Compiler::MAX_SPECIALIZATION_DEPTH` (16), enforced in the fixed-point +loop in [`Compiler::compile()`](../../src/Transpiler/Monomorphize/Compiler.php); a +fixture exercises a self-referential template that trips it. ## Pros and Cons of the Options diff --git a/docs/adr/0008-collect-or-throw-diagnostic-seam.md b/docs/adr/0008-collect-or-throw-diagnostic-seam.md index 86328c4..0dd5047 100644 --- a/docs/adr/0008-collect-or-throw-diagnostic-seam.md +++ b/docs/adr/0008-collect-or-throw-diagnostic-seam.md @@ -31,11 +31,11 @@ validators serve both modes? Chosen: an **optional `?DiagnosticCollector` parameter**. Validation methods take a nullable collector as their last argument. When it's absent (the `compile` path), they throw exactly as before. When it's present (the `check` path), each -violation is appended as a structured diagnostic and validation continues, so all -problems in a phase surface together. The user-facing text comes from a single -shared message builder used by both the `throw` and the diagnostic, so the two -can never diverge. The collector is mutable by design — it's the one sink threaded -through the validation phase. +violation is appended as a structured diagnostic and validation continues — across +every validation phase — so all problems surface in one run. The user-facing text +comes from a single shared message builder used by both the `throw` and the +diagnostic, so the two can never diverge. The collector is mutable by design — +it's the one sink threaded through the validation phases. ### Consequences @@ -43,9 +43,12 @@ through the validation phase. provably byte-identical and `check` collects — verified by tests that assert both from the same input. - Good: tiny surface area — a nullable parameter, no interface hierarchy or DI. -- Trade-off: "collect-all" means *all errors of the earliest failing phase* — - validation still halts between phases (e.g. a duplicate-definition stops the run - before instantiation-site checks), so it isn't a single flat list of everything. +- Good: in `check` mode every validation phase runs unconditionally — there is no + early return between phases — so a single run yields effectively a flat list of + all diagnostics across phases, each with its location. Two deliberate exceptions: + the inner-variance pass skips templates the variance-position pass already flagged + (to avoid double-reporting the same issue), and a generated-name hash collision is + still thrown rather than collected (it's intentionally outside the seam). - Trade-off: the no-collector default must be preserved at every call site, or that site silently reverts to fail-fast; this is covered by tests. @@ -60,8 +63,10 @@ both the byte-identical throw path and the collect path for the same fixtures. ### Optional `?DiagnosticCollector` parameter -- Good: one code path; shared message; minimal ceremony; byte-identical compile. -- Bad: a per-call-site convention to uphold; collection is per-phase, not global. +- Good: one code path; shared message; minimal ceremony; byte-identical compile; + in `check` mode all phases run and collect into one flat report. +- Bad: a per-call-site convention to uphold — every call site must preserve the + no-collector default or it silently reverts to fail-fast. ### `DiagnosticSink` interface From c5b7aba971eb80304debcb136b6e75200199398f Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 18 Jun 2026 20:35:07 +0000 Subject: [PATCH 032/114] docs: correct public docs against the code (check/PHPStan shipped, pipeline 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) --- docs/errors.md | 2 +- docs/getting-started.md | 2 +- docs/guides/how-it-works.md | 76 +++++++++++++++++++++--------- docs/roadmap.md | 26 +++++++++- docs/syntax/closures-and-arrows.md | 5 +- docs/syntax/index.md | 4 +- 6 files changed, 85 insertions(+), 30 deletions(-) diff --git a/docs/errors.md b/docs/errors.md index 493f5a4..9592308 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -47,7 +47,7 @@ The `json` and `github` formats tag each diagnostic with a stable code: | `xphp.closure_this_capture` | a generic closure/arrow used via turbofish captures `$this` (unsupported) | | `xphp.static_closure` | a generic `static` closure used via turbofish (unsupported) | | `xphp.parse_error` | the file isn't valid PHP after the generic strip pass | -| `phpstan.*` | a PHPStan finding in the compiled output, mapped back to the template declaration (the code is `phpstan.` + PHPStan's own identifier, e.g. `phpstan.return.type`) — present only when the PHPStan pass runs | +| `phpstan.*` | a PHPStan finding in the compiled output, mapped back to the template declaration (the code is `phpstan.` + PHPStan's own identifier, e.g. `phpstan.return.type`; a finding that carries no identifier falls back to the literal `phpstan.error`) — present only when the PHPStan pass runs | | `phpstan.unavailable` | (Warning) no phpstan binary was found, so the PHPStan pass was skipped | | `phpstan.run_failed` | (Warning) phpstan was found but couldn't complete (e.g. a config error) | diff --git a/docs/getting-started.md b/docs/getting-started.md index 377c1b0..4c9fcab 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -18,7 +18,7 @@ composer require --dev xphp-lang/xphp ``` This puts the compiler at `vendor/bin/xphp` and pulls in the runtime -dependencies (`nikic/php-parser`, `symfony/console`). +dependencies (`nikic/php-parser`, `symfony/console`, `symfony/process`). ## 2. Set up the PSR-4 autoload diff --git a/docs/guides/how-it-works.md b/docs/guides/how-it-works.md index 3452aee..41d824d 100644 --- a/docs/guides/how-it-works.md +++ b/docs/guides/how-it-works.md @@ -102,8 +102,9 @@ and reattach** trick implemented in 2. **Scan** for generic clauses -- `Name<...>` patterns -- with depth tracking so arbitrarily nested `Box>` constructs are handled. -3. **Strip** every `<...>` clause from the source text, producing - plain valid PHP, AND remember the original byte spans. +3. **Blank** every `<...>` clause by overwriting it with spaces of + equal length, producing plain valid PHP while keeping every byte + offset and line number identical to the original source. 4. **Parse** the stripped source with nikic. 5. **Reattach** the generic metadata to the resulting AST as node attributes (`ATTR_GENERIC_PARAMS` on ClassLike declarations, @@ -111,24 +112,31 @@ and reattach** trick implemented in `ATTR_METHOD_GENERIC_PARAMS` / `ATTR_METHOD_GENERIC_ARGS` on method-scope generics). -The strip step would lose original-source positions, which matters -for editor diagnostics ("the offending `` is at line 12, column -5"). That's what +Because the `<...>` clauses are blanked with equal-length spaces, +generic-clause stripping needs no position bookkeeping at all -- AST +offsets round-trip to the original source for free. The one +length-changing rewrite is the `T[]` array-suffix sugar: `T[]` (3 +bytes) becomes `array` (5 bytes), which shifts every offset to its +right. That's what [`ByteOffsetMap`](../../src/Transpiler/Monomorphize/ByteOffsetMap.php) -solves: it records each removal so any later byte offset in the -stripped source can be translated back into the original. The pair -of (AST, ByteOffsetMap) is returned together as +solves: it records each length-changing segment so any later byte +offset in the stripped source can be translated back into the +original -- which matters for editor diagnostics ("the offending +`` is at line 12, column 5"). When no length-changing +replacement happened the map is the identity and returns the offset +unchanged. The pair of (AST, ByteOffsetMap) is returned together as [`ParseWithMapResult`](../../src/Transpiler/Monomorphize/ParseWithMapResult.php). -Two parser entry points exist: +The parser exposes strict and tolerant modes, each in a with-map and +without-map variant: -- `parse()` -- strict mode, throws on parse errors. Used by - `bin/xphp compile` because compilation must fail on broken - source. -- `parseTolerantWithMap()` -- recovers from trailing parse errors - by feeding the stripped source through nikic's error-handler- - collecting mode. Used when callers need partial results from - incomplete source. +- `parse()` / `parseWithMap()` -- strict mode, throws on parse + errors. `bin/xphp compile` uses the strict path because + compilation must fail on broken source. +- `parseTolerant()` / `parseTolerantWithMap()` -- recover from + trailing parse errors by feeding the stripped source through + nikic's error-handler-collecting mode. Used when callers need + partial results from incomplete source. --- @@ -266,6 +274,25 @@ the runaway. --- +## Stage 4.5 -- Variance-edge emission + +Once the fixed-point loop has recorded *every* specialization (and not +before -- the comparison is pairwise across the full set), a single +pass over the specialized ASTs wires up the real subtype edges that +declaration-site variance promises. +[`VarianceEdgeEmitter::emitEdges()`](../../src/Transpiler/Monomorphize/VarianceEdgeEmitter.php) +walks each pair of specializations of the same variant template and, +where the type arguments are related the right way, adds the +`extends` / `implements` link between them: `Producer` +actually `extends Producer` when `Banana extends Fruit` and +`T` is covariant (`+T`), dually for contravariant (`-T`). The edges +are appended to the cloned specialization's `implements` / `extends` +list and survive the next stage untouched -- the rewriter only +rewrites *template* `Class_` / `Interface_` nodes, not specialized +ones. + +--- + ## Stage 5 -- Rewriting and emission Two transformations happen during rewrite, both implemented in @@ -383,17 +410,19 @@ birthday collisions are impossible at any practical project size. ### Position fidelity -`ByteOffsetMap` carries the bytes-stripped-and-where info so any -diagnostic span computed after the strip can be translated back to -the original `.xphp` source. +Generic-clause blanking preserves positions on its own, so the only +length-changing rewrite is the `T[]` → `array` sugar. `ByteOffsetMap` +records those segments so any diagnostic span computed after the strip +can be translated back to the original `.xphp` source; with no such +rewrite it's the identity map. --- ## Class roster -Every class under +The core classes under [`src/Transpiler/Monomorphize/`](../../src/Transpiler/Monomorphize/), -grouped by role. +grouped by role (validators and bound-AST nodes omitted for clarity). ```mermaid mindmap @@ -415,6 +444,7 @@ mindmap Specialize Specializer GenericMethodCompiler + VarianceEdgeEmitter Rewrite CallSiteRewriter Emit @@ -438,7 +468,9 @@ and runs bound checks; `RegistryCollector` walks ASTs to populate it; **Specialize** -- `Specializer` substitutes type-params in a cloned template AST to produce a concrete specialization; `GenericMethodCompiler` -does the analogous job for method-scope and free-function generics. +does the analogous job for method-scope and free-function generics; and +`VarianceEdgeEmitter` wires the subtype edges between specializations of +a variant template once they all exist. **Rewrite** -- `CallSiteRewriter` swaps generic Name references for the specialization's generated FQN and replaces generic ClassLike diff --git a/docs/roadmap.md b/docs/roadmap.md index 519d195..582d5b7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -47,6 +47,11 @@ timeline Developer experience : RFC-aligned call-site syntax : empty turbofish for all-defaults templates + Validation and diagnostics + : xphp check validate-only gate + : collect-all diagnostics with text json github renderers + : undeclared-type and arity validation + : PHPStan over the compiled output section Next Editor and tooling : Live transpilation via stream wrapper @@ -71,7 +76,6 @@ timeline : Variadic type parameters : Per-arg specialization Ecosystem - : phpstan bridge : REPL and playground : Migration tooling from PHPDoc Explorations @@ -179,6 +183,24 @@ upcoming one. - RFC-aligned call-site syntax (`Name::<...>` turbofish). - Empty turbofish (`Name::<>`) for all-defaults templates. +### Validation and diagnostics + +- `xphp check` — a validate-only gate that emits no code: it runs + every generic validation and reports **all** problems in one pass + (collect-all), each with a `file:line` location and a stable code, + exiting 0 (clean) / 1 (errors) / 2 (bad input). +- Structured diagnostics with `text`, `json`, and `github` + (Actions-annotation) renderers, so the same check drives both local + use and CI. +- Undeclared-type-parameter and over-arity validation (a member, + bound, or default that names a stray/typo'd type; more type + arguments than the template declares). +- PHPStan over the compiled output: `check` compiles to a throwaway + directory, runs *your* PHPStan over the monomorphized code (one + representative per template), and maps findings back to the `.xphp` + template. Opt-out via `--no-phpstan`; an absent binary degrades to a + non-failing Warning; never bundled in the PHAR. + --- ## Next @@ -238,7 +260,7 @@ to ship. ### Ecosystem -- phpstan / psalm bridge. +- Psalm bridge (the PHPStan bridge has shipped — see Shipped above). - REPL / playground. - Migration tooling: lift PHPDoc `@template` annotations to xphp generic params. diff --git a/docs/syntax/closures-and-arrows.md b/docs/syntax/closures-and-arrows.md index e9b3128..e175553 100644 --- a/docs/syntax/closures-and-arrows.md +++ b/docs/syntax/closures-and-arrows.md @@ -86,8 +86,9 @@ ref-ness is preserved end-to-end. [caveats](../caveats.md#this-capturing-arrows-and-closures-rejected). - > ⚠️ **`static` closures rejected** — `static function(...)` is - rejected because there's no `$this`-binding target on the - dispatcher. Use a named generic function instead. See + rejected because generic static closures can't yet be specialized at + the call site (a capability gap, not a binding one). Use a named + generic function at file scope instead. See [caveats](../caveats.md#static-closures-not-supported). - > ⚠️ **Variance markers not allowed** — `+T` / `-T` are rejected on diff --git a/docs/syntax/index.md b/docs/syntax/index.md index ab2c1d8..ae22a5f 100644 --- a/docs/syntax/index.md +++ b/docs/syntax/index.md @@ -59,8 +59,8 @@ class Sortable> {} // F-bounded class Pair {} // Variance -class Producer<+T> { public function get(): T; } // covariant -class Consumer<-T> { public function set(T $x): void; } // contravariant +abstract class Producer<+T> { abstract public function get(): T; } // covariant +abstract class Consumer<-T> { abstract public function set(T $x): void; } // contravariant // Default type params class Cache {} From e9a3ed76f75dbda94dd02402e0da15d04cc6903e Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 18 Jun 2026 21:33:59 +0000 Subject: [PATCH 033/114] docs: correct the static-closure rejection rationale in caveats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Why" for the static-closure caveat blamed a missing $this-binding target on the dispatcher. That's the reason for the *separate* $this-capture rejection, not this one: a static closure has no $this to begin with. The real reason is a capability gap — call-site specialization of static (and explicit use(...)) closures is an unimplemented branch of the anonymous-template rewrite. This aligns the caveat with docs/syntax/closures-and-arrows.md, docs/errors.md, and the emitted message ("cannot yet be specialized at call sites"). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/caveats.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/caveats.md b/docs/caveats.md index 4799252..4745a01 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -100,10 +100,15 @@ to a named function. ### Why -The dispatcher closure that xphp emits to route specialized calls -needs a `$this`-binding target for one of the planned future -extensions. `static` closures explicitly block `$this` binding, -which removes that hook. +Specializing an anonymous template at its call site landed in stages. +Plain generic closures and arrows are rewritten through the dispatcher +today; `static` closures (alongside explicit `use (...)` closures) are +a still-unimplemented branch of that rewrite. It's a capability gap, +not a binding one — a `static` closure has no `$this` to begin with, so +this is unrelated to the [`$this`-capture +rejection](#this-capturing-arrows-and-closures-rejected) above. The +named-function path is already complete, so lifting the body to a +file-scope generic function side-steps it. ### ✅ Workaround From 7f8be381c9857de04ef7d331c01a99a4aa5b1b1a Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 18 Jun 2026 22:46:20 +0000 Subject: [PATCH 034/114] feat(monomorphize): resolve generic instance methods through inheritance A generic method declared on a base class is now dispatched when called via turbofish on a subclass receiver (instance and nullsafe). On a receiver-keyed template miss, resolution walks the receiver's ancestor chain via the new TypeHierarchy::ancestorChain (breadth-first, nearest-first), and the specialization is emitted onto the *declaring* class so every subclass inherits the single copy through the existing class-level extends edge. A subclass that redeclares the method shadows the inherited one (direct hit wins over the ancestor walk), and the dedup key is the declaring FQN so two subclasses sharing a base method don't each append a duplicate. Previously such a call produced no specialization and fataled at runtime with "Call to undefined method". Static turbofish keeps its current behavior. Adds a runtime fixture (generic base/subclass) plus override, multi-level, intermediate-override and shared-base dedup coverage, and ancestorChain unit tests. Full suite green, PHPStan level 9 clean, 100% MSI on the diff. Co-Authored-By: Claude Opus 4.8 (1M context) --- infection.json5 | 4 +- .../Monomorphize/GenericMethodCompiler.php | 68 ++++- src/Transpiler/Monomorphize/TypeHierarchy.php | 34 +++ .../GenericMethodIntegrationTest.php | 234 ++++++++++++++++++ .../Monomorphize/TypeHierarchyTest.php | 47 ++++ .../source/Base.xphp | 13 + .../source/Derived.xphp | 9 + .../source/Use.xphp | 9 + .../verify/runtime.php | 20 ++ 9 files changed, 426 insertions(+), 12 deletions(-) create mode 100644 test/fixture/compile/generic_method_through_inheritance/source/Base.xphp create mode 100644 test/fixture/compile/generic_method_through_inheritance/source/Derived.xphp create mode 100644 test/fixture/compile/generic_method_through_inheritance/source/Use.xphp create mode 100644 test/fixture/compile/generic_method_through_inheritance/verify/runtime.php diff --git a/infection.json5 b/infection.json5 index 1c5684e..ebd59a2 100644 --- a/infection.json5 +++ b/infection.json5 @@ -768,12 +768,14 @@ // Inside the cycle-safe BFS of TypeHierarchy::isSubtype, `$visited[$cur] = true` // is only consumed via `isset($visited[$cur])` — the value is never read. The // TrueValue → false mutation is observationally identical (the KEY is what tracks - // visit-state, not the VALUE). + // visit-state, not the VALUE). TypeHierarchy::ancestorChain uses the same + // `$seen[$next] = true` + `isset()` dedup, so its TrueValue mutant is equivalent too. // Same pattern in GenericMethodCompiler::rewriteCallSites — `$alreadyGenerated[key] = true` // is consumed via `isset()`, never via value read. "TrueValue": { "ignore": [ "XPHP\\Transpiler\\Monomorphize\\TypeHierarchy::isSubtype", + "XPHP\\Transpiler\\Monomorphize\\TypeHierarchy::ancestorChain", "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler", // VarianceEdgeEmitter::filterDirectSupers: the // `$impliedByAnother = true; break;` shortcut. Mutating diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index c9f3903..2f23697 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -63,16 +63,23 @@ * 3. Strip the original generic-method ClassMethod from each class. * 4. Rewrite each StaticCall's Identifier name to the mangled form. * - * MVP limitations (called out so they're not silently surprising): - * - Static call sites only — `ClassFqn::method(...)`. Instance calls `$obj->method(...)` - * are not yet supported because the compiler has no way to know the runtime class of `$obj` - * without proper type inference. - * - Methods declared on non-generic classes only. Calling a generic method on a generic class - * (where the method has its own distinct type-param) requires merging two type-param scopes; - * that's a follow-up. + * Supported call shapes: static (`ClassFqn::method::(...)`), instance and nullsafe + * (`$obj->method::(...)`, `$obj?->method::(...)`) via receiver-type analysis, and + * free functions / generic closures / arrows. A generic method declared on a generic OR a + * non-generic class works; the method's own type-params are a scope disjoint from the class's, + * so `class Box { public function map(...) }` specializes `U` independently of `T`. + * + * Inheritance: an instance/nullsafe turbofish call resolves the method through the receiver's + * ancestor chain (`resolveMethodTemplate`), and the specialization is emitted onto the + * *declaring* class so every subclass inherits the single copy. A subclass override (same + * method redeclared) shadows the inherited one. + * + * Limitations: * - Bound validation on method-level type-params fires when a TypeHierarchy is wired in * (compiler always passes one); if none is given (bare unit tests), bounds become * advisory — matching the class-level Registry's behavior. + * - Inheritance resolution walks `extends`/`implements` ancestors only; a generic method + * reached solely through a `use`d trait is not resolved (the trait isn't in the hierarchy). */ final class GenericMethodCompiler { @@ -1015,10 +1022,15 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): } $methodName = $node->name->toString(); $key = $classFqn . '::' . $methodName; - $template = $this->methodTemplates[$key] ?? null; - if ($template === null) { + // Resolve through the inheritance chain: a generic method declared on + // a base class is callable on a subclass receiver. $declaringFqn is + // where the template actually lives, so the specialization is emitted + // there and inherited (see resolveMethodTemplate). + $resolved = $this->resolveMethodTemplate($classFqn, $methodName); + if ($resolved === null) { return null; } + [$template, $declaringFqn] = $resolved; $params = $template->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); if (!is_array($params)) { return null; @@ -1050,14 +1062,17 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): } $mangled = self::mangleName($methodName, $args, $this->hashLength); - $generatedKey = $classFqn . '::' . $mangled; + // Key emission + dedup by the DECLARING class, not the receiver: the + // specialization lands on the base and every subclass inherits the one + // copy. Keying by receiver would append a duplicate per subclass. + $generatedKey = $declaringFqn . '::' . $mangled; if (!isset($this->alreadyGenerated[$generatedKey])) { $substitution = []; foreach ($params as $i => $param) { $substitution[$param->name] = $args[$i]; } $specialized = (new Specializer())->specializeMethod($template, $substitution, $mangled); - $owner = $this->classByFqn[$classFqn] ?? null; + $owner = $this->classByFqn[$declaringFqn] ?? null; if ($owner !== null) { $this->pendingAppends[] = [$owner, $specialized]; $this->alreadyGenerated[$generatedKey] = true; @@ -1070,6 +1085,37 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): return $node; } + /** + * Resolve a generic-method template by receiver FQN, walking up the + * inheritance chain when the method is declared on an ancestor. + * + * Returns the template paired with the FQN of the class that actually + * declares it -- the specialization is emitted onto that declaring class + * so every subclass inherits it through the existing class-level extends + * edge. A direct hit on the receiver's own class wins over the ancestor + * walk (a subclass override shadows an inherited method). Returns null + * when neither the receiver nor any ancestor declares the method. + * + * @return array{0: ClassMethod, 1: string}|null [template, declaringFqn] + */ + private function resolveMethodTemplate(string $receiverFqn, string $methodName): ?array + { + $direct = $this->methodTemplates[$receiverFqn . '::' . $methodName] ?? null; + if ($direct !== null) { + return [$direct, $receiverFqn]; + } + if ($this->hierarchy === null) { + return null; + } + foreach ($this->hierarchy->ancestorChain($receiverFqn) as $ancestorFqn) { + $inherited = $this->methodTemplates[$ancestorFqn . '::' . $methodName] ?? null; + if ($inherited !== null) { + return [$inherited, $ancestorFqn]; + } + } + return null; + } + /** * Resolve the static type (FQN) of a method-call receiver expression. * Returns null when the receiver type can't be determined -- the caller diff --git a/src/Transpiler/Monomorphize/TypeHierarchy.php b/src/Transpiler/Monomorphize/TypeHierarchy.php index d5bb52d..8285163 100644 --- a/src/Transpiler/Monomorphize/TypeHierarchy.php +++ b/src/Transpiler/Monomorphize/TypeHierarchy.php @@ -138,6 +138,40 @@ public function isDeclared(string $fqn): bool return isset($this->ancestors[$fqn]) || in_array($fqn, self::BUILTIN_TYPES, true); } + /** + * Transitive ancestors of $fqn, nearest-first and de-duplicated, excluding + * $fqn itself. Breadth-first over the direct-ancestor map, so the closest + * declaring class is visited before its grandparents. + * + * Powers inherited generic-method resolution: when `$sub->m::()` can't + * bind `m` on the receiver's own class, the caller walks this chain and + * binds to the nearest ancestor that declares the generic method. An + * unknown $fqn yields an empty list. + * + * @return list + */ + public function ancestorChain(string $fqn): array + { + $fqn = ltrim($fqn, '\\'); + + $seen = []; + $chain = []; + $queue = $this->ancestors[$fqn] ?? []; + while ($queue !== []) { + $next = array_shift($queue); + if (isset($seen[$next])) { + continue; + } + $seen[$next] = true; + $chain[] = $next; + foreach ($this->ancestors[$next] ?? [] as $grandAncestor) { + $queue[] = $grandAncestor; + } + } + + return $chain; + } + /** * @param list $ast * @param array> $ancestors out-param accumulator diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php index 18251bc..2884e3a 100644 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php @@ -1257,6 +1257,240 @@ public function makeParent(T $v): Parent_ { } } + #[RunInSeparateProcess] + public function testGenericMethodResolvesThroughInheritance(): void + { + // Ticket 0003: a generic method declared on a base class resolves and + // runs when called via turbofish on a subclass receiver. The + // specialization is emitted onto the DECLARING base so every subclass + // inherits the single copy through the class-level `extends` edge -- + // it is NOT duplicated onto the receiver's own specialization. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_method_through_inheritance/source', + 'genmethod-inherit', + ); + try { + $generated = self::globRecursive($fixture->cacheDir . '/Generated', '*.php'); + + $baseSpec = ''; + $derivedSpec = ''; + foreach ($generated as $f) { + $content = file_get_contents($f); + self::assertIsString($content); + if (str_contains($f, '/Base/T_')) { + $baseSpec .= $content; + } + if (str_contains($f, '/Derived/T_')) { + $derivedSpec .= $content; + } + } + + // Both `identity` specializations ( and ) land on Base. + self::assertSame( + 2, + preg_match_all('/function identity_T_[0-9a-f]+\(/', $baseSpec), + 'both identity specializations emitted onto the declaring Base', + ); + // Derived inherits them; nothing is duplicated onto the subclass. + self::assertStringNotContainsString( + 'identity_T_', + $derivedSpec, + 'subclass inherits the base specialization; no duplicate on Derived', + ); + + $fixture->registerAutoload('App\\GenericMethodThroughInheritance'); + require __DIR__ . '/../../fixture/compile/generic_method_through_inheritance/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + public function testSubclassGenericMethodOverrideBindsToSubclass(): void + { + // The direct hit on the receiver's own class wins over the ancestor + // walk: a subclass that redeclares the generic method binds its own + // body, so the specialization lands on the subclass, not the base. + $dir = sys_get_temp_dir() . '/xphp-inh-override-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Base.xphp', <<<'PHP' + (U $x): U { return $x; } } + PHP); + file_put_contents($dir . '/Derived.xphp', <<<'PHP' + (U $x): U { return $x; } } + PHP); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + id::(5); + PHP); + + try { + $this->compileFrom($dir); + $base = file_get_contents($dir . '/dist/Base.php'); + $derived = file_get_contents($dir . '/dist/Derived.php'); + self::assertIsString($base); + self::assertIsString($derived); + // Override wins: the specialization is on Derived (the direct hit). + self::assertSame(1, preg_match_all('/function id_T_[0-9a-f]+\(/', $derived)); + self::assertStringNotContainsString('id_T_', $base); + } finally { + self::rrmdir($dir); + } + } + + public function testGenericMethodResolvesThroughMultiLevelInheritance(): void + { + // Base <- Mid <- Leaf: the method on Base resolves on a Leaf receiver, + // and the specialization lands on the nearest *declaring* ancestor + // (Base), reached via the breadth-first ancestor walk. + $dir = sys_get_temp_dir() . '/xphp-inh-chain-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Base.xphp', <<<'PHP' + (U $x): U { return $x; } } + PHP); + file_put_contents($dir . '/Mid.xphp', <<<'PHP' + id::(9); + PHP); + + try { + $this->compileFrom($dir); + $base = file_get_contents($dir . '/dist/Base.php'); + $mid = file_get_contents($dir . '/dist/Mid.php'); + $leaf = file_get_contents($dir . '/dist/Leaf.php'); + self::assertIsString($base); + self::assertIsString($mid); + self::assertIsString($leaf); + self::assertSame(1, preg_match_all('/function id_T_[0-9a-f]+\(/', $base)); + self::assertStringNotContainsString('id_T_', $mid); + self::assertStringNotContainsString('id_T_', $leaf); + } finally { + self::rrmdir($dir); + } + } + + public function testIntermediateOverrideBindsToNearestDeclaringAncestor(): void + { + // Base declares id; Mid overrides it; Leaf inherits. A call on a Leaf + // receiver binds the NEAREST declaring ancestor (Mid) -- the breadth-first + // walk returns Mid's template first, so the specialization lands on Mid, + // not on Base. + $dir = sys_get_temp_dir() . '/xphp-inh-midoverride-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Base.xphp', <<<'PHP' + (U $x): U { return $x; } } + PHP); + file_put_contents($dir . '/Mid.xphp', <<<'PHP' + (U $x): U { return $x; } } + PHP); + file_put_contents($dir . '/Leaf.xphp', <<<'PHP' + id::(3); + PHP); + + try { + $this->compileFrom($dir); + $base = file_get_contents($dir . '/dist/Base.php'); + $mid = file_get_contents($dir . '/dist/Mid.php'); + $leaf = file_get_contents($dir . '/dist/Leaf.php'); + self::assertIsString($base); + self::assertIsString($mid); + self::assertIsString($leaf); + // Nearest declaring ancestor wins: the specialization lands on Mid only. + self::assertSame(1, preg_match_all('/function id_T_[0-9a-f]+\(/', $mid)); + self::assertStringNotContainsString('id_T_', $base); + self::assertStringNotContainsString('id_T_', $leaf); + } finally { + self::rrmdir($dir); + } + } + + public function testInheritedGenericMethodDedupesOnSharedBase(): void + { + // Two subclasses calling the same inherited method with the same type + // argument emit exactly ONE specialization, on the shared base (dedup + // keyed by the declaring FQN, not the receiver). + $dir = sys_get_temp_dir() . '/xphp-inh-dedup-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Base.xphp', <<<'PHP' + (U $x): U { return $x; } } + PHP); + file_put_contents($dir . '/A.xphp', <<<'PHP' + id::(1); + $rb = $b->id::(2); + PHP); + + try { + $this->compileFrom($dir); + $base = file_get_contents($dir . '/dist/Base.php'); + self::assertIsString($base); + self::assertSame(1, preg_match_all('/function id_T_[0-9a-f]+\(/', $base)); + } finally { + self::rrmdir($dir); + } + } + private function compileFrom(string $dir): void { $compiler = $this->buildCompiler(); diff --git a/test/Transpiler/Monomorphize/TypeHierarchyTest.php b/test/Transpiler/Monomorphize/TypeHierarchyTest.php index 8ec7359..2b6e37a 100644 --- a/test/Transpiler/Monomorphize/TypeHierarchyTest.php +++ b/test/Transpiler/Monomorphize/TypeHierarchyTest.php @@ -217,4 +217,51 @@ public function testIsNotDeclaredForUnknownName(): void self::assertFalse($hierarchy->isDeclared('App\\T')); self::assertFalse($hierarchy->isDeclared('App\\Nonexistent')); } + + public function testAncestorChainReturnsNearestFirst(): void + { + $hierarchy = new TypeHierarchy([ + 'App\\Leaf' => ['App\\Mid'], + 'App\\Mid' => ['App\\Base'], + 'App\\Base' => [], + ]); + + self::assertSame(['App\\Mid', 'App\\Base'], $hierarchy->ancestorChain('App\\Leaf')); + self::assertSame(['App\\Base'], $hierarchy->ancestorChain('App\\Mid')); + self::assertSame([], $hierarchy->ancestorChain('App\\Base')); + } + + public function testAncestorChainDedupesDiamondNearestFirst(): void + { + // Diamond: D -> {B, C}; B -> A; C -> {A, E}. A is reachable via two + // paths but appears exactly once. E sits in the queue *after* the + // duplicate A, so a `break`-instead-of-`continue` on the dedup hit + // would wrongly drop E -- the assertion on E's presence pins that. + $hierarchy = new TypeHierarchy([ + 'D' => ['B', 'C'], + 'B' => ['A'], + 'C' => ['A', 'E'], + 'A' => [], + 'E' => [], + ]); + + self::assertSame(['B', 'C', 'A', 'E'], $hierarchy->ancestorChain('D')); + } + + public function testAncestorChainUnknownFqnIsEmpty(): void + { + $hierarchy = new TypeHierarchy([]); + + self::assertSame([], $hierarchy->ancestorChain('App\\Nope')); + } + + public function testAncestorChainNormalizesLeadingBackslash(): void + { + $hierarchy = new TypeHierarchy([ + 'App\\Sub' => ['App\\Sup'], + 'App\\Sup' => [], + ]); + + self::assertSame(['App\\Sup'], $hierarchy->ancestorChain('\\App\\Sub')); + } } diff --git a/test/fixture/compile/generic_method_through_inheritance/source/Base.xphp b/test/fixture/compile/generic_method_through_inheritance/source/Base.xphp new file mode 100644 index 0000000..0466271 --- /dev/null +++ b/test/fixture/compile/generic_method_through_inheritance/source/Base.xphp @@ -0,0 +1,13 @@ + +{ + public function identity(U $x): U + { + return $x; + } +} diff --git a/test/fixture/compile/generic_method_through_inheritance/source/Derived.xphp b/test/fixture/compile/generic_method_through_inheritance/source/Derived.xphp new file mode 100644 index 0000000..d27da57 --- /dev/null +++ b/test/fixture/compile/generic_method_through_inheritance/source/Derived.xphp @@ -0,0 +1,9 @@ + extends Base +{ +} diff --git a/test/fixture/compile/generic_method_through_inheritance/source/Use.xphp b/test/fixture/compile/generic_method_through_inheritance/source/Use.xphp new file mode 100644 index 0000000..148c427 --- /dev/null +++ b/test/fixture/compile/generic_method_through_inheritance/source/Use.xphp @@ -0,0 +1,9 @@ +(); +$s = $d->identity::('hi'); +$n = $d->identity::(7); diff --git a/test/fixture/compile/generic_method_through_inheritance/verify/runtime.php b/test/fixture/compile/generic_method_through_inheritance/verify/runtime.php new file mode 100644 index 0000000..840c807 --- /dev/null +++ b/test/fixture/compile/generic_method_through_inheritance/verify/runtime.php @@ -0,0 +1,20 @@ +`) declared on the base class `Base` + * resolves and runs when called via turbofish on a `Derived` subclass + * receiver. The specialization is emitted onto the declaring base and + * inherited through the class-level `extends` edge. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertSame('hi', $s); +Assert::assertSame(7, $n); From 747592ccbbb9f30c742130485a7db0ca71425518 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 18 Jun 2026 23:04:24 +0000 Subject: [PATCH 035/114] feat(monomorphize): error on unresolved generic-method turbofish calls A turbofish method call (`$obj->m::<...>()`, `$obj?->m::<...>()`, or `Foo::m::<...>()`) whose generic method can't be resolved on the receiver's type now fails at compile time (and is collected by `xphp check`) instead of being left in place to fatal at runtime with "Call to undefined method". The check is strictly gated on the turbofish marker: a plain non-generic call (no type arguments) passes through untouched, so ordinary method calls are unaffected. Compile throws and check collects the same message from one shared builder; the diagnostic carries code xphp.unresolved_generic_call at the call site. Adds a check-mode fixture plus check-collect, compile-throw (instance + static) and plain-call-passthrough tests, and documents the code in docs/errors.md. Full suite green, PHPStan level 9 clean, 100% MSI on the diff. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/errors.md | 2 + .../Monomorphize/GenericMethodCompiler.php | 52 ++++++++++++++- .../Monomorphize/CheckPassIntegrationTest.php | 22 +++++++ .../GenericMethodIntegrationTest.php | 63 +++++++++++++++++++ .../unresolved_generic_method/source/Use.xphp | 16 +++++ 5 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 test/fixture/check/unresolved_generic_method/source/Use.xphp diff --git a/docs/errors.md b/docs/errors.md index 9592308..9fbc04c 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -46,6 +46,7 @@ The `json` and `github` formats tag each diagnostic with a stable code: | `xphp.duplicate_generic_function` | the same generic function is declared in two files | | `xphp.closure_this_capture` | a generic closure/arrow used via turbofish captures `$this` (unsupported) | | `xphp.static_closure` | a generic `static` closure used via turbofish (unsupported) | +| `xphp.unresolved_generic_call` | a turbofish method call (`$obj->m::<…>()` / `Foo::m::<…>()`) names a generic method that can't be resolved on the receiver's type — a typo or wrong receiver type, caught at compile time instead of fataling at runtime | | `xphp.parse_error` | the file isn't valid PHP after the generic strip pass | | `phpstan.*` | a PHPStan finding in the compiled output, mapped back to the template declaration (the code is `phpstan.` + PHPStan's own identifier, e.g. `phpstan.return.type`; a finding that carries no identifier falls back to the literal `phpstan.error`) — present only when the PHPStan pass runs | | `phpstan.unavailable` | (Warning) no phpstan binary was found, so the PHPStan pass was skipped | @@ -114,6 +115,7 @@ In CI (GitHub Actions), one step gates the build and annotates the diff: | `cannot use itself as a bound` | [Type bounds — F-bounded](syntax/type-bounds.md) | | `already declared` ... `duplicate declaration` | [Caveats — duplicate generic template declaration](caveats.md#duplicate-generic-template-declaration) | | `was instantiated but never defined` | The template was used but no source file declared it — typo or missing import | +| `could not be resolved to a declared generic method` | A turbofish method call names a generic method that can't be resolved on the receiver's type — check the method name or the receiver's type. | | `was instantiated with N type argument(s) but parameter ... has no default` | [Defaults](syntax/defaults.md) — supply all required args or add defaults | | `Nested generic specialization exceeded depth` | A generic refers to itself transitively too deeply (compiler aborts at depth 16) — usually a recursive instantiation cycle. Refactor to break the cycle. | | `Parser returned null AST` | The source file isn't valid PHP after the generic strip pass. Run `php -l .xphp` mentally on the cleaned source — most often a syntax error in the user code that's unrelated to generics. | diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 2f23697..1d04924 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -87,6 +87,7 @@ final class GenericMethodCompiler public const CODE_DUPLICATE_GENERIC_FUNCTION = 'xphp.duplicate_generic_function'; public const CODE_UNSUPPORTED_THIS_CAPTURE = 'xphp.closure_this_capture'; public const CODE_UNSUPPORTED_STATIC_CLOSURE = 'xphp.static_closure'; + public const CODE_UNRESOLVED_GENERIC_CALL = 'xphp.unresolved_generic_call'; /** * @param ?DiagnosticCollector $diagnostics When null (the default — `xphp compile`), every @@ -930,7 +931,7 @@ private function rewriteStaticCall(StaticCall $node): ?Node $key = $classFqn . '::' . $methodName; $template = $this->methodTemplates[$key] ?? null; if ($template === null) { - return null; + return $this->reportUnresolvedTurbofishOrSkip($classFqn, $methodName, $node); } $params = $template->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); if (!is_array($params)) { @@ -1028,7 +1029,7 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): // there and inherited (see resolveMethodTemplate). $resolved = $this->resolveMethodTemplate($classFqn, $methodName); if ($resolved === null) { - return null; + return $this->reportUnresolvedTurbofishOrSkip($classFqn, $methodName, $node); } [$template, $declaringFqn] = $resolved; $params = $template->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); @@ -1116,6 +1117,53 @@ private function resolveMethodTemplate(string $receiverFqn, string $methodName): return null; } + /** + * Handle a turbofish call whose generic method couldn't be resolved on + * the receiver or any ancestor. A *turbofish* call (carries + * ATTR_METHOD_GENERIC_ARGS) to a non-existent generic method is a real + * user error -- reported here instead of being silently left in place + * to fatal at runtime with "Call to undefined method". An ordinary + * (non-turbofish) call has no generic args and is none of our business, + * so it passes through untouched. + * + * Collect-or-throw, matching the seam: with a collector (`check`) append + * a diagnostic and continue; without one (`compile`) throw. + */ + private function reportUnresolvedTurbofishOrSkip(string $receiverFqn, string $methodName, Node $node): null + { + $args = $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); + if (!is_array($args)) { + // Plain (non-turbofish) call -- not a generic-resolution failure. + return null; + } + $message = self::unresolvedGenericCallMessage($receiverFqn, $methodName); + if ($this->diagnostics !== null) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + GenericMethodCompiler::CODE_UNRESOLVED_GENERIC_CALL, + $message, + new SourceLocation($this->currentFile, $node->getStartLine()), + )); + return null; + } + throw new RuntimeException($message); + } + + private static function unresolvedGenericCallMessage(string $receiverFqn, string $methodName): string + { + // Phrased as "could not be resolved ... on " rather than + // asserting the method is absent everywhere: the instance path walks + // ancestors, but the static path doesn't yet, so an absolute "not on + // any ancestor" claim would be wrong for an inherited static method. + return sprintf( + 'Generic method `%s::%s::<...>()` could not be resolved to a declared generic ' + . 'method on `%s`. Check the method name or the receiver\'s type.', + $receiverFqn, + $methodName, + $receiverFqn, + ); + } + /** * Resolve the static type (FQN) of a method-call receiver expression. * Returns null when the receiver type can't be determined -- the caller diff --git a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php index eaee99a..cb9f357 100644 --- a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php +++ b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php @@ -173,6 +173,28 @@ public function testCompileStillThrowsOnStaticGenericClosure(): void $this->compileFixture('closure_static'); } + public function testUnresolvedGenericMethodTurbofishIsCollectedByCheck(): void + { + // A turbofish call to a generic method that exists nowhere on the receiver + // or its ancestors is a collected error (not a silent pass-through that + // would fatal at runtime). + $diagnostics = $this->check('unresolved_generic_method'); + + self::assertCount(1, $diagnostics->all()); + $d = $diagnostics->all()[0]; + self::assertSame(GenericMethodCompiler::CODE_UNRESOLVED_GENERIC_CALL, $d->code); + self::assertNotNull($d->location); + self::assertSame(16, $d->location->line); + self::assertStringContainsString('nope', $d->message); + } + + public function testCompileStillThrowsOnUnresolvedGenericMethodTurbofish(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('could not be resolved'); + $this->compileFixture('unresolved_generic_method'); + } + public function testClassAndMethodLevelErrorsAreBothCollectedInOneRun(): void { // Proves the validation-superset guarantee: a class-level check (variance) AND a diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php index 2884e3a..842d7c6 100644 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php @@ -1491,6 +1491,69 @@ class B extends Base {} } } + public function testPlainNonGenericCallIsNotFlaggedAsUnresolvedGeneric(): void + { + // The unresolved-generic error fires only for turbofish calls. A plain + // (non-turbofish) call to a non-template method passes through untouched + // -- it is ordinary PHP, not a generic-resolution failure -- while a + // turbofish call to a method that DOES exist still specializes. + $dir = sys_get_temp_dir() . '/xphp-plaincall-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Box.xphp', <<<'PHP' + (T $x): T { return $x; } } + PHP); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + nope(1); + $r = $b->get::(2); + PHP); + + try { + $this->compileFrom($dir); // must NOT throw + $use = file_get_contents($dir . '/dist/Use.php'); + self::assertIsString($use); + // Plain call survives untouched; the turbofish call specializes. + self::assertStringContainsString('$b->nope(1)', $use); + self::assertSame(1, preg_match_all('/get_T_[0-9a-f]+\(/', $use)); + } finally { + self::rrmdir($dir); + } + } + + public function testUnresolvedStaticGenericMethodTurbofishFailsCompilation(): void + { + // The unresolved-generic error also covers the static turbofish path: + // `Box::nope::()` where `nope` is not a generic method on Box. + $dir = sys_get_temp_dir() . '/xphp-unresolved-static-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Box.xphp', <<<'PHP' + (T $x): T { return $x; } } + PHP); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + (1); + PHP); + + try { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('could not be resolved'); + $this->compileFrom($dir); + } finally { + self::rrmdir($dir); + } + } + private function compileFrom(string $dir): void { $compiler = $this->buildCompiler(); diff --git a/test/fixture/check/unresolved_generic_method/source/Use.xphp b/test/fixture/check/unresolved_generic_method/source/Use.xphp new file mode 100644 index 0000000..005a46b --- /dev/null +++ b/test/fixture/check/unresolved_generic_method/source/Use.xphp @@ -0,0 +1,16 @@ +(T $x): T + { + return $x; + } +} + +$b = new Box(); +$r = $b->nope::(1); From 094b6ef1efe6e5755489972591c3247025a1a4ab Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 18 Jun 2026 23:11:28 +0000 Subject: [PATCH 036/114] feat(monomorphize): resolve static + nullsafe generic methods through inheritance Extends the inheritance resolution to the static turbofish path: a static generic method declared on a base class is now callable as `Sub::m::<...>()`, resolved through the receiver's ancestor chain and emitted onto the declaring base (reached via PHP static-method inheritance). The nullsafe instance path (`$obj?->m::<...>()`) already shared the instance code path; it is now locked with coverage including the `?->` short-circuit. The call's class reference stays the receiver (`Sub::m_T_hash()`), so late static binding is preserved; only the specialization's emission moves to the declaring class. Same-class static calls keep their byte-identical output. Adds a static runtime fixture plus nullsafe-inherited and parent::-inherited coverage. Full suite green, PHPStan level 9 clean, 100% MSI on the diff. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/GenericMethodCompiler.php | 14 ++- .../GenericMethodIntegrationTest.php | 109 ++++++++++++++++++ .../source/Base.xphp | 13 +++ .../source/Derived.xphp | 9 ++ .../source/Use.xphp | 8 ++ .../verify/runtime.php | 21 ++++ 6 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 test/fixture/compile/generic_static_method_through_inheritance/source/Base.xphp create mode 100644 test/fixture/compile/generic_static_method_through_inheritance/source/Derived.xphp create mode 100644 test/fixture/compile/generic_static_method_through_inheritance/source/Use.xphp create mode 100644 test/fixture/compile/generic_static_method_through_inheritance/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 1d04924..cc8c7ba 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -929,10 +929,14 @@ private function rewriteStaticCall(StaticCall $node): ?Node $classFqn = $this->resolveClassName($node->class); $methodName = $node->name->toString(); $key = $classFqn . '::' . $methodName; - $template = $this->methodTemplates[$key] ?? null; - if ($template === null) { + // Resolve through the inheritance chain (same as the instance path): + // a static generic method declared on a base is callable as + // `Sub::m::<...>()` and resolves via static-method inheritance. + $resolved = $this->resolveMethodTemplate($classFqn, $methodName); + if ($resolved === null) { return $this->reportUnresolvedTurbofishOrSkip($classFqn, $methodName, $node); } + [$template, $declaringFqn] = $resolved; $params = $template->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); if (!is_array($params)) { return null; @@ -968,14 +972,16 @@ private function rewriteStaticCall(StaticCall $node): ?Node } $mangled = self::mangleName($methodName, $args, $this->hashLength); - $generatedKey = $classFqn . '::' . $mangled; + // Emit onto the declaring class (see the instance path) so subclasses + // inherit the single specialization; dedup by the declaring FQN. + $generatedKey = $declaringFqn . '::' . $mangled; if (!isset($this->alreadyGenerated[$generatedKey])) { $substitution = []; foreach ($params as $i => $param) { $substitution[$param->name] = $args[$i]; } $specialized = (new Specializer())->specializeMethod($template, $substitution, $mangled); - $owner = $this->classByFqn[$classFqn] ?? null; + $owner = $this->classByFqn[$declaringFqn] ?? null; if ($owner !== null) { // Buffer the append (see rewriteFuncCall for the rationale). $this->pendingAppends[] = [$owner, $specialized]; diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php index 842d7c6..18320ad 100644 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php @@ -1305,6 +1305,115 @@ public function testGenericMethodResolvesThroughInheritance(): void } } + #[RunInSeparateProcess] + public function testStaticGenericMethodResolvesThroughInheritance(): void + { + // Ticket 0003 (static path): a static generic method declared on Base + // resolves and runs when called as `Derived::make::<...>()`. The + // specialization is emitted onto the declaring Base and reached via + // PHP's static-method inheritance. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_static_method_through_inheritance/source', + 'genmethod-static-inherit', + ); + try { + $base = file_get_contents($fixture->targetDir . '/Base.php'); + $derived = file_get_contents($fixture->targetDir . '/Derived.php'); + self::assertIsString($base); + self::assertIsString($derived); + // Both make specializations land on Base; Derived inherits them. + self::assertSame(2, preg_match_all('/function make_T_[0-9a-f]+\(/', $base)); + self::assertStringNotContainsString('make_T_', $derived); + + require __DIR__ . '/../../fixture/compile/generic_static_method_through_inheritance/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + public function testNullsafeInheritedGenericMethodResolves(): void + { + // Nullsafe instance turbofish resolves through inheritance too (it shares + // the instance path). The specialization lands on the declaring base and + // the call is rewritten while preserving the `?->` short-circuit operator. + $dir = sys_get_temp_dir() . '/xphp-inh-nullsafe-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Base.xphp', <<<'PHP' + (U $x): U { return $x; } } + PHP); + file_put_contents($dir . '/Derived.xphp', <<<'PHP' + id::(5); + } + } + PHP); + + try { + $this->compileFrom($dir); + $base = file_get_contents($dir . '/dist/Base.php'); + $caller = file_get_contents($dir . '/dist/Caller.php'); + self::assertIsString($base); + self::assertIsString($caller); + // Specialization on the declaring base; nullsafe operator preserved. + self::assertSame(1, preg_match_all('/function id_T_[0-9a-f]+\(/', $base)); + self::assertMatchesRegularExpression('/\$d\?->id_T_[0-9a-f]+\(/', $caller); + } finally { + self::rrmdir($dir); + } + } + + public function testParentTurbofishResolvesInheritedStaticGenericMethod(): void + { + // A static generic method inherited from the parent now resolves via the + // ancestor walk (before the static path walked ancestors this was an + // "unresolved generic method" compile error). + $dir = sys_get_temp_dir() . '/xphp-inh-parent-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Base.xphp', <<<'PHP' + (U $x): U { return $x; } } + PHP); + file_put_contents($dir . '/Child.xphp', <<<'PHP' + (1); } + } + PHP); + + try { + $this->compileFrom($dir); // must NOT throw + $base = file_get_contents($dir . '/dist/Base.php'); + $child = file_get_contents($dir . '/dist/Child.php'); + self::assertIsString($base); + self::assertIsString($child); + self::assertSame(1, preg_match_all('/function make_T_[0-9a-f]+\(/', $base)); + // The inherited static call resolved to a mangled specialization (the + // `parent::` receiver resolves to the current class, which inherits the + // base method); no leftover turbofish marker survives. + self::assertMatchesRegularExpression('/::make_T_[0-9a-f]+\(/', $child); + self::assertStringNotContainsString('make::<', $child); + } finally { + self::rrmdir($dir); + } + } + public function testSubclassGenericMethodOverrideBindsToSubclass(): void { // The direct hit on the receiver's own class wins over the ancestor diff --git a/test/fixture/compile/generic_static_method_through_inheritance/source/Base.xphp b/test/fixture/compile/generic_static_method_through_inheritance/source/Base.xphp new file mode 100644 index 0000000..2328a6e --- /dev/null +++ b/test/fixture/compile/generic_static_method_through_inheritance/source/Base.xphp @@ -0,0 +1,13 @@ +(U $x): U + { + return $x; + } +} diff --git a/test/fixture/compile/generic_static_method_through_inheritance/source/Derived.xphp b/test/fixture/compile/generic_static_method_through_inheritance/source/Derived.xphp new file mode 100644 index 0000000..f365885 --- /dev/null +++ b/test/fixture/compile/generic_static_method_through_inheritance/source/Derived.xphp @@ -0,0 +1,9 @@ +('hi'); +$n = Derived::make::(7); diff --git a/test/fixture/compile/generic_static_method_through_inheritance/verify/runtime.php b/test/fixture/compile/generic_static_method_through_inheritance/verify/runtime.php new file mode 100644 index 0000000..0256913 --- /dev/null +++ b/test/fixture/compile/generic_static_method_through_inheritance/verify/runtime.php @@ -0,0 +1,21 @@ +`) declared on `Base` resolves and runs when + * called as `Derived::make::<...>()` on a subclass. The specialization is emitted + * onto Base and reached through PHP's static-method inheritance. + * + * Driver contract: `$fixture` (CompiledFixture) in scope. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Base.php'; +require $fixture->targetDir . '/Derived.php'; +require $fixture->targetDir . '/Use.php'; + +Assert::assertSame('hi', $s); +Assert::assertSame(7, $n); From f9dfc7a422425f836c2abb12e722c8bd17984a21 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 18 Jun 2026 23:15:14 +0000 Subject: [PATCH 037/114] docs: document generic-method resolution through inheritance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit methods-and-functions.md gains an Inheritance section: a generic method on a base/abstract class is callable via turbofish on a subclass receiver (instance, static, nullsafe), resolved through the ancestor chain and specialized once on the declaring class; a subclass override shadows it; an unresolved turbofish is a compile-time error. turbofish.md notes the same on its receiver-type-analysis section and cross-links the xphp.unresolved_generic_call code. roadmap.md records both shipped changes — "generic methods inherited from base classes" under Generic templates / Function-level generics, and "unresolved-generic-call detection" under Validation and diagnostics (timeline overview + prose). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/roadmap.md | 9 +++++++ docs/syntax/methods-and-functions.md | 36 ++++++++++++++++++++++++++++ docs/syntax/turbofish.md | 12 ++++++++++ 3 files changed, 57 insertions(+) diff --git a/docs/roadmap.md b/docs/roadmap.md index 582d5b7..e12021a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -21,6 +21,7 @@ timeline Generic templates : classes and interfaces and traits : methods on static and instance receivers + : methods inherited from base classes : free functions at any scope : closures and arrow functions Bounds @@ -51,6 +52,7 @@ timeline : xphp check validate-only gate : collect-all diagnostics with text json github renderers : undeclared-type and arity validation + : unresolved-generic-call detection : PHPStan over the compiled output section Next Editor and tooling @@ -112,6 +114,10 @@ upcoming one. ### Function-level generics - Generic methods on static and instance receivers. +- Generic methods resolved through inheritance: a method declared on a + base (or abstract) class is callable via turbofish on a subclass + receiver (instance, static, nullsafe), emitted once on the declaring + class and inherited. - Generic free functions at namespace scope and bare top-level. - Nullsafe instance turbofish (`$obj?->m::()`). - Receiver-type analysis for `$this`, typed parameters, typed @@ -195,6 +201,9 @@ upcoming one. - Undeclared-type-parameter and over-arity validation (a member, bound, or default that names a stray/typo'd type; more type arguments than the template declares). +- Unresolved-generic-call detection: a turbofish method call whose + generic method exists on neither the receiver nor any ancestor is a + compile-time error instead of a silent runtime fatal. - PHPStan over the compiled output: `check` compiles to a throwaway directory, runs *your* PHPStan over the monomorphized code (one representative per template), and maps findings back to the `.xphp` diff --git a/docs/syntax/methods-and-functions.md b/docs/syntax/methods-and-functions.md index d58bd42..6c7c333 100644 --- a/docs/syntax/methods-and-functions.md +++ b/docs/syntax/methods-and-functions.md @@ -75,6 +75,40 @@ swap_T_e5f3...(1, 'one'); - Receiver-type analysis picks the right specialization when the receiver is `$this`, a typed param, a typed property, or a local `$x = new Foo()` assignment. +- A generic method declared on a base class is callable through + inheritance — see below. + +## Inheritance + +A generic method declared on a base (or abstract) class is callable via +turbofish on a **subclass** receiver, for instance, static, and nullsafe +calls. Resolution walks the receiver's ancestor chain (nearest first), and +the specialization is emitted **once** on the declaring class, so every +subclass inherits the single body. A subclass that redeclares the method +shadows the inherited one. + +```php +abstract class AbstractCollection { + // A generic helper shared by every concrete collection. + public function wrap(U $value): U { + return $value; + } +} +class Collection extends AbstractCollection {} + +$c = new Collection::(); +// `wrap` isn't declared on Collection — it resolves to +// AbstractCollection::wrap, specialized once on the base and inherited: +$s = $c->wrap::('hi'); +``` + +A turbofish call to a generic method that exists on neither the receiver +nor any of its ancestors is a **compile-time error** +([`xphp.unresolved_generic_call`](../errors.md#diagnostic-codes)), caught at +build time instead of fataling at runtime with "Call to undefined method". + +> Resolution follows `extends`/`implements` ancestors. A generic method +> reached only through a `use`d trait is not resolved through inheritance. ## Caveats @@ -92,5 +126,7 @@ swap_T_e5f3...(1, 'one'); - Test fixture: `test/fixture/compile/generic_method/` - Test fixture: `test/fixture/compile/generic_function/` +- Test fixture: `test/fixture/compile/generic_method_through_inheritance/` +- Test fixture: `test/fixture/compile/generic_static_method_through_inheritance/` - Related: [closures and arrows](closures-and-arrows.md), [turbofish](turbofish.md) diff --git a/docs/syntax/turbofish.md b/docs/syntax/turbofish.md index a3ee7d9..d99cb8d 100644 --- a/docs/syntax/turbofish.md +++ b/docs/syntax/turbofish.md @@ -88,6 +88,18 @@ If the analysis can't prove a single class (e.g., `$x` reassigned inside a branch where the arms disagree), the call drops to a non-specialized path rather than picking a possibly-wrong class. +Once the receiver class is known, the method is resolved through its +**inheritance chain** (nearest ancestor first), so a generic method +declared on a base class is callable on a subclass receiver. The same +holds for the static (`Sub::m::()`) and nullsafe (`$x?->m::()`) +shapes. See +[methods and functions → inheritance](methods-and-functions.md#inheritance). + +A turbofish call whose generic method can't be resolved on the receiver +or any of its ancestors is a **compile-time error** +([`xphp.unresolved_generic_call`](../errors.md#diagnostic-codes)) rather +than a silent pass-through that fatals at runtime. + ## Caveats - > ⚠️ **Branching narrowing precision** — receiver-type analysis From a9cafeafbc772829dca681ff5203dad624b0ec84 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 19 Jun 2026 05:18:47 +0000 Subject: [PATCH 038/114] docs(roadmap): record deferred generic-completeness gaps Adds a "Generic completeness" group under Discovery for deferred limitations of already-shipped generics that weren't represented anywhere in the roadmap: - $this-capturing and static generic closures (currently rejected; lift planned) - generic methods inherited through traits (extends/implements resolve; traits don't) - trait composition for variance validation and bound satisfaction (unmodeled) These were documented only in caveats/code comments before; the roadmap now reflects them (timeline overview + prose). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/roadmap.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/roadmap.md b/docs/roadmap.md index e12021a..69db4f0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -64,6 +64,10 @@ timeline : Generic type aliases : Variance edges on trait-owned templates : Branching narrowing precision + Generic completeness + : this-capturing and static generic closures + : generic methods inherited via traits + : trait composition for variance and bounds Module surface : internal visibility modifier : composer-package boundary @@ -239,6 +243,20 @@ to ship. - Branching narrowing precision: today conservatively de-specializes when arms disagree; will track unions with runtime dispatch instead. +### Generic completeness + +Gaps in already-shipped generics, deferred rather than designed out: + +- `$this`-capturing and `static` generic closures: both are rejected + today; lifting them (rewrite `$this->x` to a passed parameter; route + `static` closures through the dispatcher) is planned. +- Generic methods inherited through traits: resolution follows + `extends` / `implements` ancestors, but a generic method reached only + via a `use`d trait is not yet resolved. +- Trait composition for variance and bounds: the variance-position + validator doesn't walk trait-imported method signatures, and bound + satisfaction doesn't follow trait chains — both currently unmodeled. + ### Module surface - **`internal` visibility modifier**: replace PHPDoc `@internal` hints From 7513764565e7cd1499ca532642b03885d30aca93 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 19 Jun 2026 13:08:20 +0000 Subject: [PATCH 039/114] feat(monomorphize): allow a type-parameter-typed constructor on a variant class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A covariant/contravariant class may now take its type parameter in a (non-promoted) constructor parameter — e.g. a covariant immutable `ImmutableList<+T>` built from `T ...$items`. Each specialization emits that constructor parameter variance-erased (the type-param's bound if a single non-generic leaf, else `mixed`), so every specialization's `__construct` signature is byte-identical and stays LSP-compatible across the variance `extends` edge — a concrete `T`-typed constructor would PHP-fatal at autoload. For the same reason, `final` is stripped from variant-class specializations (an edge to a final parent would fatal); these generated classes are internal (referenced via the marker interface / turbofish call site), so this is invisible. `final` is preserved on invariant-class specializations. Guards kept intact: promoted constructor params are properties and stay strictly invariant (rejected); a non-bare `T` in a constructor (`?T`, `Box`, `T|X`) is NOT erased and is still rejected by the inner-variance check, since it would fatal across the edge; the mutable/invariant case is unchanged. Note: construction is accepted but not runtime-type-checked (the erased parameter accepts `mixed`); call-site argument checking is a separate follow-up. Covered by runtime + structural fixtures (covariant, contravariant, bounded, mixed-variance, two-param, invariant-kept-final) and rejection tests. Full suite green, PHPStan level 9 clean, 100% MSI on the diff. Co-Authored-By: Claude Opus 4.8 (1M context) --- infection.json5 | 24 ++- src/Transpiler/Monomorphize/Compiler.php | 1 + .../Monomorphize/InnerVarianceValidator.php | 34 ++++ src/Transpiler/Monomorphize/Specializer.php | 120 ++++++++++- .../VariancePositionValidator.php | 16 +- .../VarianceEdgeIntegrationTest.php | 189 ++++++++++++++++++ .../VariancePositionPhaseTest.php | 9 +- .../source/Banana.xphp | 13 ++ .../source/Fruit.xphp | 12 ++ .../source/ImmutableList.xphp | 29 +++ .../source/Use.xphp | 16 ++ .../verify/runtime.php | 20 ++ 12 files changed, 476 insertions(+), 7 deletions(-) create mode 100644 test/fixture/compile/generic_covariant_immutable_ctor/source/Banana.xphp create mode 100644 test/fixture/compile/generic_covariant_immutable_ctor/source/Fruit.xphp create mode 100644 test/fixture/compile/generic_covariant_immutable_ctor/source/ImmutableList.xphp create mode 100644 test/fixture/compile/generic_covariant_immutable_ctor/source/Use.xphp create mode 100644 test/fixture/compile/generic_covariant_immutable_ctor/verify/runtime.php diff --git a/infection.json5 b/infection.json5 index ebd59a2..71322c9 100644 --- a/infection.json5 +++ b/infection.json5 @@ -296,6 +296,16 @@ "XPHP\\Transpiler\\Monomorphize\\Specializer::specialize", "XPHP\\Transpiler\\Monomorphize\\Specializer::specializeMethod", "XPHP\\Transpiler\\Monomorphize\\Specializer::specializeFunction", + // Specializer::applyConstructorErasures: the `if ($erasures === []) return;` + // fast-exit. Dropping it falls through to findConstructor + an empty foreach + // (a no-op), producing identical output. + "XPHP\\Transpiler\\Monomorphize\\Specializer::applyConstructorErasures", + // InnerVarianceValidator::isErasedVariantCtorParam: the promoted-param + // `return false` guard is unreachable in practice — a covariant class with a + // promoted (property) type-param is flagged by the variance-POSITION pass + // first, and inner-variance skips position-flagged definitions, so this branch + // never decides anything observable. + "XPHP\\Transpiler\\Monomorphize\\InnerVarianceValidator::isErasedVariantCtorParam", // Specializer::substituteTypeRef: dropping `return $ref` on the empty-args // branch falls through to array_map over empty + ctor, producing an // equivalent TypeRef. Pure micro-optimisation, no observable difference. @@ -387,7 +397,12 @@ // just above). Negating both sub-exprs yields `!A || !B`, which is // ALWAYS true because $bound can't be both operand types at once -- // the mutated assert can never fail. Observationally equivalent. - "XPHP\\Transpiler\\Monomorphize\\VariancePositionValidator::checkBoundExpr" + "XPHP\\Transpiler\\Monomorphize\\VariancePositionValidator::checkBoundExpr", + // Specializer::applyConstructorErasures: the `assert($erased instanceof + // Identifier || $erased instanceof Name)` type-narrowing guard. typeRefToNode + // only ever returns one of those two for an erased ref, so negating the + // sub-exprs yields an assert that still holds — observationally equivalent. + "XPHP\\Transpiler\\Monomorphize\\Specializer::applyConstructorErasures" ] }, @@ -636,7 +651,12 @@ // before this optimization). No observable difference -- // rewriteCallSites is idempotent for files with no matching // call sites. - "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler" + "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler", + // Specializer::variantConstructorErasures: the `mixed` erasure ref + // `new TypeRef('mixed', [], true, false)` — the final `false` is isTypeParam. + // typeRefToNode branches on isScalar (true) first and emits Identifier('mixed') + // regardless of isTypeParam, so toggling it to `true` is unobservable. + "XPHP\\Transpiler\\Monomorphize\\Specializer::variantConstructorErasures" ] }, // XphpSourceParser::applyReplacements: dropping the usort() entirely. diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 6b37851..9176920 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -140,6 +140,7 @@ public function compile( $specialized = $this->specializer->specialize( $definition->templateAst, $substitution, + $definition->typeParams, ); $specializedAsts[$generatedFqn] = $specialized; diff --git a/src/Transpiler/Monomorphize/InnerVarianceValidator.php b/src/Transpiler/Monomorphize/InnerVarianceValidator.php index 19d4e24..9775373 100644 --- a/src/Transpiler/Monomorphize/InnerVarianceValidator.php +++ b/src/Transpiler/Monomorphize/InnerVarianceValidator.php @@ -10,6 +10,7 @@ use PhpParser\Node\IntersectionType; use PhpParser\Node\Name; use PhpParser\Node\NullableType; +use PhpParser\Node\Param; use PhpParser\Node\UnionType; use RuntimeException; use XPHP\Diagnostics\Diagnostic; @@ -109,6 +110,16 @@ private function collect(GenericDefinition $definition): void // ctor signatures regardless of param flavor. `getProperties()` // below skips promoted ones (they're `Param`, not `Property`), // so each promoted property is walked exactly once. + // + // Exception: a non-promoted ctor param typed by a bare + // covariant/contravariant type-param is emitted variance-erased + // (`mixed`/bound) by the Specializer, so its declared variance is + // irrelevant — skip it. Inner-generic ctor params (e.g. + // `Container`) are NOT erased and stay checked, as do promoted + // params (they're properties). + if ($isCtor && $this->isErasedVariantCtorParam($param)) { + continue; + } $outerPos = $isCtor ? Variance::Invariant : Variance::Contravariant; if ($param->type !== null) { $this->walkPhpType($param->type, $outerPos, $label, null, null); @@ -133,6 +144,29 @@ private function collect(GenericDefinition $definition): void } } + /** + * A non-promoted constructor parameter whose type is a bare single-segment + * covariant/contravariant type-param — exactly the params the Specializer + * emits variance-erased. Their declared variance no longer reaches the + * emitted signature, so the inner-variance walk skips them. + */ + private function isErasedVariantCtorParam(Param $param): bool + { + if ($param->flags !== 0) { + return false; // promoted param == property; stays strictly invariant. + } + $type = $param->type; + if (!$type instanceof Name) { + return false; + } + $parts = $type->getParts(); + if (count($parts) !== 1) { + return false; // inner-generic / qualified type — not erased, keep checking. + } + $variance = $this->varianceMap[$parts[0]] ?? null; + return $variance !== null && $variance !== Variance::Invariant; + } + /** * @infection-ignore-all -- semantic-equivalent mutants in this walker: * fall-through returns for unhandled node kinds, `??`-suppressed null-safe diff --git a/src/Transpiler/Monomorphize/Specializer.php b/src/Transpiler/Monomorphize/Specializer.php index 51a9081..89154c0 100644 --- a/src/Transpiler/Monomorphize/Specializer.php +++ b/src/Transpiler/Monomorphize/Specializer.php @@ -7,7 +7,9 @@ use PhpParser\Node; use PhpParser\Node\Identifier; use PhpParser\Node\Name; +use PhpParser\Modifiers; use PhpParser\Node\Name\FullyQualified; +use PhpParser\Node\Param; use PhpParser\Node\Stmt\Class_; use PhpParser\Node\Stmt\ClassLike; use PhpParser\Node\Stmt\ClassMethod; @@ -40,14 +42,22 @@ final class Specializer { /** * @param array $substitution Type-param name → concrete TypeRef. + * @param list $typeParams The template's type-params, used to + * variance-erase constructor parameters typed by a covariant/contravariant + * `T`. Empty (the default) disables erasure — callers that don't have the + * params, e.g. unit tests, get the plain substitution. * * The cloned class's `name` is intentionally NOT set here — SpecializedClassGenerator::emit * is the single source of truth for the final shortname (derived from the generated FQCN). */ - public function specialize(ClassLike $template, array $substitution): ClassLike + public function specialize(ClassLike $template, array $substitution, array $typeParams = []): ClassLike { $originalTemplateFqn = $template->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); + // Record which constructor parameters must be variance-erased BEFORE the + // substituting visitor rewrites their `T` type to the concrete type. + $ctorErasures = self::variantConstructorErasures($template, $typeParams); + $cloned = self::deepClone($template); assert($cloned instanceof ClassLike); $cloned->setAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS, null); @@ -66,11 +76,119 @@ public function specialize(ClassLike $template, array $substitution): ClassLike } } + // A variant class's specializations participate in `extends` subtype + // edges (VarianceEdgeEmitter); a `final` parent in that chain would + // PHP-fatal at autoload. These generated classes are internal — user + // code references the marker interface or the turbofish call site, never + // these names — so dropping `final` here is invisible and safe. + if ($cloned instanceof Class_ && self::hasVariantParam($typeParams)) { + $cloned->flags &= ~Modifiers::FINAL; + } + self::runSubstitutingVisitor($cloned, $substitution); + // Re-type the recorded constructor params to their erased (bound / `mixed`) + // form so every specialization's `__construct` signature is identical and + // stays LSP-compatible across the variance `extends` edge (a concrete + // `T`-typed ctor would PHP-fatal at autoload — see ticket 0005). + self::applyConstructorErasures($cloned, $ctorErasures); + return $cloned; } + /** + * For a variant template, find the NON-promoted `__construct` parameters typed + * by a covariant/contravariant type-param and compute the type to emit instead + * of the concrete one: the param's bound when it's a single non-generic leaf, + * else `mixed`. Promoted params are skipped — they are properties and stay + * strictly invariant (rejected upstream by the variance validator). + * + * @param list $typeParams + * @return array constructor-parameter index → erased TypeRef + */ + private static function variantConstructorErasures(ClassLike $template, array $typeParams): array + { + $erasedByName = []; + foreach ($typeParams as $typeParam) { + if ($typeParam->variance === Variance::Invariant) { + continue; + } + $erasedByName[$typeParam->name] = + ($typeParam->bound instanceof BoundLeaf && !$typeParam->bound->type->isGeneric()) + ? $typeParam->bound->type + : new TypeRef('mixed', [], true, false); + } + if ($erasedByName === []) { + return []; + } + + $ctor = self::findConstructor($template); + if ($ctor === null) { + return []; + } + + $erasures = []; + foreach ($ctor->params as $i => $param) { + if ($param->flags !== 0) { + continue; // promoted params are properties — left invariant/rejected. + } + $type = $param->type; + if ($type instanceof Name && count($type->getParts()) === 1) { + $name = $type->getParts()[0]; + if (isset($erasedByName[$name])) { + $erasures[$i] = $erasedByName[$name]; + } + } + } + return $erasures; + } + + /** + * @param array $erasures constructor-parameter index → erased TypeRef + */ + private static function applyConstructorErasures(ClassLike $cloned, array $erasures): void + { + if ($erasures === []) { + return; + } + $ctor = self::findConstructor($cloned); + if ($ctor === null) { + return; + } + foreach ($erasures as $i => $erasedRef) { + $param = $ctor->params[$i] ?? null; + if ($param instanceof Param) { + // The erased type is a fresh synthetic node (`mixed` or a bound), so + // no source-position attributes are carried over. typeRefToNode returns + // an Identifier or a (Fully)Qualified Name — both valid for Param::$type. + $erased = self::typeRefToNode($erasedRef, []); + assert($erased instanceof Identifier || $erased instanceof Name); + $param->type = $erased; + } + } + } + + /** @param list $typeParams */ + private static function hasVariantParam(array $typeParams): bool + { + foreach ($typeParams as $typeParam) { + if ($typeParam->variance !== Variance::Invariant) { + return true; + } + } + return false; + } + + private static function findConstructor(ClassLike $node): ?ClassMethod + { + foreach ($node->getMethods() as $method) { + if ($method->name->toLowerString() === '__construct') { + return $method; + } + } + return null; + } + /** * Specialize a single generic method: clone the ClassMethod, substitute every * Name reference to a type-param with the matching concrete TypeRef, drop the diff --git a/src/Transpiler/Monomorphize/VariancePositionValidator.php b/src/Transpiler/Monomorphize/VariancePositionValidator.php index 5d62c9a..bd76eba 100644 --- a/src/Transpiler/Monomorphize/VariancePositionValidator.php +++ b/src/Transpiler/Monomorphize/VariancePositionValidator.php @@ -205,14 +205,26 @@ private function checkMethod(ClassMethod $method): void ? [Variance::Invariant] : [Variance::Invariant, Variance::Contravariant]; $paramPosition = $isConstructor ? 'constructor parameter' : 'method parameter'; + // A variant class (≥1 covariant/contravariant type-param) may carry its + // type-param in a NON-promoted constructor parameter: the specializer emits + // that parameter variance-erased (bound or `mixed`), so its signature is + // identical across the `extends` chain and LSP-safe. A *promoted* ctor + // param is also a property, which stays strictly invariant (a `T`-typed + // property would PHP-fatal across the chain regardless). + $classIsVariant = $this->varianceByName !== []; foreach ($method->params as $param) { // @phpstan-ignore-next-line instanceof.alwaysTrue — defensive guard against nikic/php-parser PHPDoc-narrowed param collection element. if (!$param instanceof Param) { continue; } - if ($param->type !== null) { - $this->checkPhpType($param->type, $paramAllowed, $paramPosition); + if ($param->type === null) { + continue; } + $isPromoted = $param->flags !== 0; + $allowed = ($isConstructor && !$isPromoted && $classIsVariant) + ? [Variance::Invariant, Variance::Covariant, Variance::Contravariant] + : $paramAllowed; + $this->checkPhpType($param->type, $allowed, $paramPosition); } // Return type. Constructors don't have one; for the rest, invariant diff --git a/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php b/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php index 9cdd309..5e9c5f5 100644 --- a/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php +++ b/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php @@ -6,11 +6,13 @@ use PhpParser\ParserFactory; use PhpParser\PrettyPrinter\Standard as StandardPrinter; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; use RuntimeException; use XPHP\FileSystem\FileFinder\NativeFileFinder; use XPHP\FileSystem\FileReader\NativeFileReader; use XPHP\FileSystem\FileWriter\NativeFileWriter; +use XPHP\TestSupport\CompiledFixture; use XPHP\TestSupport\SnapshotHash; final class VarianceEdgeIntegrationTest extends TestCase @@ -34,6 +36,137 @@ protected function tearDown(): void } } + #[RunInSeparateProcess] + public function testCovariantImmutableCollectionTakesTypedConstructorInput(): void + { + // Ticket 0005: a covariant immutable collection `ImmutableList<+T>` with a + // `T`-typed constructor. The ctor param is emitted variance-erased (`mixed`) + // so `ImmutableList` extends `ImmutableList` with NO PHP + // autoload fatal, and a Banana list is usable where a Fruit list is expected. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_covariant_immutable_ctor/source', + 'variance-covariant-ctor', + ); + try { + $specDir = $fixture->cacheDir . '/Generated/App/CovariantCtor/ImmutableList'; + $files = glob($specDir . '/T_*.php') ?: []; + self::assertCount(2, $files, 'two ImmutableList specializations (Fruit, Banana)'); + + $combined = ''; + $extendsEdges = 0; + foreach ($files as $file) { + $content = file_get_contents($file); + self::assertIsString($content); + $combined .= $content; + if (str_contains($content, 'extends \\XPHP\\Generated\\App\\CovariantCtor\\ImmutableList\\T_')) { + $extendsEdges++; + } + } + // Both specializations emit the variance-erased `mixed` constructor. + self::assertSame( + 2, + preg_match_all('/function __construct\(mixed \.\.\.\$items\)/', $combined), + 'both ctors variance-erased to `mixed ...$items`', + ); + // Exactly one specialization extends the other — the covariant edge. + self::assertSame(1, $extendsEdges, 'ImmutableList extends ImmutableList'); + // `final` is stripped from variant-class specializations so the edge's + // parent isn't a final class (which would PHP-fatal at autoload). + self::assertStringNotContainsString('final class', $combined); + + $fixture->registerAutoload('App\\CovariantCtor'); + require __DIR__ . '/../../fixture/compile/generic_covariant_immutable_ctor/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + public function testBoundedCovariantConstructorErasesToTheBound(): void + { + // A bounded covariant ctor param erases to the BOUND (not `mixed`), so the + // emitted signature stays chain-identical AND keeps a coarse runtime check. + $generated = $this->compileInlineAndReadGenerated([ + 'Box.xphp' => "\n{\n private array \$items;\n public function __construct(T ...\$items) { \$this->items = \$items; }\n public function get(int \$i): T { return \$this->items[\$i]; }\n}\n", + 'Tag.xphp' => " "(new Tag());\n", + ]); + self::assertStringContainsString('__construct(\\Stringable ...$items)', $generated); + self::assertStringNotContainsString('__construct(mixed', $generated); + } + + public function testMixedVarianceConstructorErasesOnlyTheVariantParam(): void + { + // `Pair<+A, B>`: the covariant `A` ctor param erases to `mixed`; the + // invariant `B` param keeps its concrete substituted type; and a plain + // scalar param (`int $tag`) is left untouched (it isn't a type-param). + $generated = $this->compileInlineAndReadGenerated([ + 'Pair.xphp' => "\n{\n private array \$slots;\n public function __construct(A \$a, B \$b, int \$tag) { \$this->slots = [\$a, \$b, \$tag]; }\n public function first(): A { return \$this->slots[0]; }\n}\n", + 'Apple.xphp' => " "(new Apple(), new Apple(), 5);\n", + ]); + self::assertMatchesRegularExpression('/__construct\(mixed \$a, \\\\App\\\\MixedCtor\\\\Apple \$b, int \$tag\)/', $generated); + } + + public function testContravariantConstructorParamIsAlsoErased(): void + { + // Symmetry with the covariant case: a `-T` ctor param erases to `mixed` + // too, and the contravariant edge (Consumer extends Consumer) + // stays LSP-safe with identical erased ctors. + $generated = $this->compileInlineAndReadGenerated([ + 'Consumer.xphp' => "\n{\n private array \$items;\n public function __construct(T ...\$items) { \$this->items = \$items; }\n public function accept(T \$x): void { \$this->items[] = \$x; }\n}\n", + 'Fruit.xphp' => " " "();\n\$b = new Consumer::();\n", + ]); + self::assertSame(2, preg_match_all('/function __construct\(mixed \.\.\.\$items\)/', $generated)); + self::assertStringContainsString('extends \\XPHP\\Generated\\App\\ContraCtor\\Consumer\\T_', $generated); + } + + public function testNonErasableVariantConstructorParamsAreStillRejected(): void + { + // Only a *bare* covariant type-param ctor param is variance-erased. These + // shapes are NOT erased (they'd PHP-fatal across the edge), so the + // inner-variance check must still reject them: + // - `?T` — nullable, not a bare Name + // - `Box` — T through another generic's invariant slot + // - `(T $a, ?T $b)` — the erased leading `T` must not stop the walk from + // reaching the bad trailing `?T` + $cases = [ + "class P<+T>\n{\n public function __construct(?T \$x) {}\n}\n", + "class Box {}\nclass P<+T>\n{\n public function __construct(Box \$b) {}\n}\n", + "class P<+T>\n{\n public function __construct(T \$a, ?T \$b) {}\n}\n", + ]; + foreach ($cases as $i => $body) { + $this->compileExpectingVarianceViolation("compileInlineAndReadGenerated([ + 'Two.xphp' => "\n{\n private array \$slots;\n public function __construct(A \$a, B \$b) { \$this->slots = [\$a, \$b]; }\n public function getA(): A { return \$this->slots[0]; }\n public function getB(): B { return \$this->slots[1]; }\n}\n", + 'Apple.xphp' => " "(new Apple(), new Apple());\n", + ]); + self::assertSame(1, preg_match_all('/__construct\(mixed \$a, mixed \$b\)/', $generated)); + } + + public function testInvariantClassConstructorIsNotErasedAndKeepsFinal(): void + { + // An invariant class is not variance-erased (ctor param keeps its concrete + // type) and its `final` modifier is preserved (no edges → no LSP hazard). + $generated = $this->compileInlineAndReadGenerated([ + 'Holder.xphp' => "\n{\n public function __construct(public T \$item) {}\n}\n", + 'Apple.xphp' => " "(new Apple());\n", + ]); + self::assertStringContainsString('final class', $generated); + self::assertStringContainsString('App\\InvCtor\\Apple $item', $generated); + self::assertStringNotContainsString('mixed $item', $generated); + } + public function testCovariantSubtypeEdgeIsEmittedAsExtendsForClassSpecializations(): void { // Fixture: `variance_covariant_happy/`. Producer<+T>, Banana <: Fruit. @@ -505,6 +638,62 @@ private function writeAutoloadCheck( return $loader; } + /** + * Compile inline `.xphp` sources and return the concatenated text of every + * generated specialization, for asserting on emitted constructor signatures. + * + * @param array $files filename → xphp source + */ + private function compileInlineAndReadGenerated(array $files): string + { + $src = $this->workDir . '/src'; + if (!is_dir($src)) { + mkdir($src, 0o755, true); + } + foreach ($files as $name => $code) { + file_put_contents($src . '/' . $name, $code); + } + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($src) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + $compiler->compile($sources, $src, $this->targetDir, $this->cacheDir); + + $generatedDir = $this->cacheDir . '/Generated'; + if (!is_dir($generatedDir)) { + return ''; + } + $out = ''; + $iter = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($generatedDir)); + foreach ($iter as $file) { + if ($file->isFile() && str_ends_with($file->getFilename(), '.php')) { + $out .= file_get_contents($file->getPathname()) . "\n"; + } + } + return $out; + } + + /** + * Compile a single inline source in a throwaway dir and assert it raises a + * variance violation (compile-mode, fail-fast). + */ + private function compileExpectingVarianceViolation(string $source): void + { + $dir = sys_get_temp_dir() . '/xphp-iv-' . uniqid('', true); + mkdir($dir . '/src', 0o755, true); + file_put_contents($dir . '/src/S.xphp', $source); + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($dir . '/src') + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + try { + $compiler->compile($sources, $dir . '/src', $dir . '/dist', $dir . '/cache'); + self::fail('expected a variance violation, none thrown'); + } catch (RuntimeException $e) { + self::assertStringContainsString('Variance violation', $e->getMessage()); + } finally { + self::rrmdir($dir); + } + } + private function fqnToPath(string $fqn): string { $prefix = Registry::GENERATED_NAMESPACE_PREFIX . '\\'; diff --git a/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php b/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php index f9145d7..baa91ec 100644 --- a/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php +++ b/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php @@ -44,8 +44,13 @@ public static function rejectedSources(): iterable ">\n{\n public function get(): T { throw new \\LogicException; }\n}\n", ['bound'], ]; - yield 'covariant in constructor parameter' => [ - "\n{\n public function __construct(T \$item) {}\n public function get(): T { throw new \\LogicException; }\n}\n", + // NOTE: `+T` in a *non-promoted* constructor parameter of a variant class is + // now ALLOWED (ticket 0005) — it is emitted variance-erased, so it's no longer + // a variance-position violation. See VarianceEdgeIntegrationTest's covariant + // immutable-collection test. A *promoted* ctor param is a PROPERTY, which stays + // strictly invariant (a `T`-typed property would PHP-fatal across the chain): + yield 'covariant in promoted constructor property' => [ + "\n{\n public function __construct(public T \$item) {}\n}\n", ['constructor parameter'], ]; yield 'covariant in nested closure parameter' => [ diff --git a/test/fixture/compile/generic_covariant_immutable_ctor/source/Banana.xphp b/test/fixture/compile/generic_covariant_immutable_ctor/source/Banana.xphp new file mode 100644 index 0000000..d4c5bae --- /dev/null +++ b/test/fixture/compile/generic_covariant_immutable_ctor/source/Banana.xphp @@ -0,0 +1,13 @@ + extends +// ImmutableList) stays LSP-safe. +final class ImmutableList<+T> +{ + private array $items; + + public function __construct(T ...$items) + { + $this->items = $items; + } + + public function get(int $i): T + { + return $this->items[$i]; + } + + public function count(): int + { + return \count($this->items); + } +} diff --git a/test/fixture/compile/generic_covariant_immutable_ctor/source/Use.xphp b/test/fixture/compile/generic_covariant_immutable_ctor/source/Use.xphp new file mode 100644 index 0000000..1ce829e --- /dev/null +++ b/test/fixture/compile/generic_covariant_immutable_ctor/source/Use.xphp @@ -0,0 +1,16 @@ + is accepted where ImmutableList +// is expected, because Banana extends Fruit and T is covariant. +function firstName(ImmutableList $list): string +{ + return $list->get(0)->name; +} + +$bananas = new ImmutableList::(new Banana(), new Banana()); +$cnt = $bananas->count(); +$name = firstName($bananas); diff --git a/test/fixture/compile/generic_covariant_immutable_ctor/verify/runtime.php b/test/fixture/compile/generic_covariant_immutable_ctor/verify/runtime.php new file mode 100644 index 0000000..17f984e --- /dev/null +++ b/test/fixture/compile/generic_covariant_immutable_ctor/verify/runtime.php @@ -0,0 +1,20 @@ +` with a `T`-typed + * constructor. `ImmutableList` is usable where `ImmutableList` + * is expected (covariant `extends` edge), and the variance-erased `mixed` + * constructor does NOT PHP-fatal at autoload across that edge. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertSame(2, $cnt); +Assert::assertSame('banana', $name); From e43ae2b62ba7d0084ddd4f1f6db54c01b6bd6dec Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 19 Jun 2026 13:12:50 +0000 Subject: [PATCH 040/114] docs(variance): make method-level variance a documented permanent boundary Variance markers on method/function/closure/arrow type parameters were rejected with a "not yet supported" message that implied a pending feature. It isn't: a function or closure specialization has no stable class identity to anchor a subtype `extends` edge to, so variance is class-level only by design (matching Kotlin, whose `fun map(...)` is invariant). Reword the parser error to say so, and reframe the caveat + CHANGELOG known-limitation as a permanent boundary. Adds an arrow-function rejection test alongside the existing method/free-function/ closure ones so all four shapes are pinned. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 +++-- docs/caveats.md | 21 ++++++++++++------- .../Monomorphize/XphpSourceParser.php | 9 +++++--- .../Monomorphize/XphpSourceParserTest.php | 16 +++++++++++++- 4 files changed, 37 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index baeee90..079a1e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -109,8 +109,9 @@ for a hands-on look at every feature below. These are documented in full in the [caveats](docs/caveats.md): -- Variance markers (`+T` / `-T`) are class-level only — not yet on - methods, free functions, closures, or arrows. +- Variance markers (`+T` / `-T`) are class-level only **by design** — not + supported on methods, free functions, closures, or arrows (a function or + closure specialization has no stable class identity for a subtype edge). - Generic closures and arrows that capture `$this`, and `static` closures, are rejected at the call site. - Reflection and closure serializers see the dispatcher shape, not the diff --git a/docs/caveats.md b/docs/caveats.md index 4745a01..6992430 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -137,18 +137,23 @@ $arrow = fn<+T>(T $x): T => $x; // arrow ``` ``` -Variance markers `+T` / `-T` are not yet supported on methods, -functions, closures, or arrow functions; move the generic to a -class-level type parameter. +Variance markers `+T` / `-T` are not supported on methods, functions, +closures, or arrow functions — variance is a class-level-only feature by +design: a function or closure specialization has no stable class identity +to anchor a subtype `extends` edge to. Move the generic to a class-level +type parameter. ``` ### Why -Variance turns into real `extends` chains between specialized classes -(see [variance](syntax/variance.md)). Methods, functions, closures, -and arrows don't have a stable identity to anchor an `extends` chain -to — their specializations are functions, not classes, so there's -nothing for the subtype edge to attach to. +This is a **permanent design boundary**, not a pending feature. Variance turns +into real `extends` chains between specialized classes (see +[variance](syntax/variance.md)). Methods, functions, closures, and arrows +don't have a stable class identity to anchor an `extends` chain to — their +specializations are functions, not classes, so there's nothing for the subtype +edge to attach to. (This matches Kotlin, whose `fun map(...)` is likewise +invariant.) Keep variance at the class level and let method-level type +parameters stay invariant. ### ✅ Workaround diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index e726c69..a1e5f94 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -611,9 +611,12 @@ private static function parseTypeParamList( if ($i < $n && ($tokens[$i]->text === '+' || $tokens[$i]->text === '-')) { if (!$allowVariance) { throw new RuntimeException( - 'Variance markers `+T` / `-T` are not yet supported on ' - . 'methods, functions, closures, or arrow functions; ' - . 'move the generic to a class-level type parameter.', + 'Variance markers `+T` / `-T` are not supported on methods, ' + . 'functions, closures, or arrow functions — variance is a ' + . 'class-level-only feature by design: a function or closure ' + . 'specialization has no stable class identity to anchor a ' + . 'subtype `extends` edge to. Move the generic to a class-level ' + . 'type parameter.', ); } $variance = $tokens[$i]->text === '+' diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index 2c0b42d..c05bcf5 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -2216,7 +2216,7 @@ public function id<+T>(T $x): T { return $x; } PHP; $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Variance markers `+T` / `-T` are not yet supported on methods, functions, closures, or arrow functions'); + $this->expectExceptionMessage('Variance markers `+T` / `-T` are not supported on methods, functions, closures, or arrow functions'); $parser->parse($source); } @@ -2542,6 +2542,20 @@ public function testGenericClosureVarianceIsRejected(): void $parser->parse($source); } + public function testArrowFunctionVarianceIsRejected(): void + { + $source = <<<'PHP' +(T $x): T => $x; +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Variance markers'); + $this->expectExceptionMessage('closures, or arrow functions'); + $parser->parse($source); + } + /** * @template TNode of Node * @param array $ast From 0b74eb670fd874adb2b1457f7648576e2638a10c Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 19 Jun 2026 15:02:47 +0000 Subject: [PATCH 041/114] feat(check): recognize a `Hashable` value-equality bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `Hashable` to the bounds whitelist so `Set` and `Map` are expressible and compile-time bound-checked — the foundation for value-semantic sets and object-keyed maps in a collections library. Referenced fully-qualified (`\Hashable`) or via `use`, the same as the built-in `\Stringable` bound. xphp ships no runtime `Hashable` interface — it stays a pure transpiler. The consumer (or their collection library) provides the contract (`hashCode(): int|string`, `equals(self): bool`); the bound only gives that hand-written hashing/keying code a type-checked contract. Bounds emit no runtime code, so whitelisting the name is purely static and safe. docs/caveats.md replaces the silently-broken `$items[$k] = $v` object-key workaround (which fatals for object keys) with the `\Hashable`-bound pattern. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/caveats.md | 33 ++++++-- src/Transpiler/Monomorphize/TypeHierarchy.php | 6 ++ .../BoundedGenericIntegrationTest.php | 78 +++++++++++++++++++ .../Monomorphize/TypeHierarchyTest.php | 15 ++++ 4 files changed, 126 insertions(+), 6 deletions(-) diff --git a/docs/caveats.md b/docs/caveats.md index 6992430..591b6db 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -397,17 +397,38 @@ class Map { } ``` -If you need a typed key/value container, build it from a generic -class: +PHP array keys are `int|string` only, so `$this->items[$k] = $v` works +only when `K` is a string/int — it **fatals for object keys**. For a +container keyed on (or deduplicating) arbitrary objects, bound the type +parameter on `\Hashable`, the recognized value-equality bound, and key on +`hashCode()` internally: ```php -class Map { - private array $items = []; - public function set(K $k, V $v): void { $this->items[$k] = $v; } - public function get(K $k): V { return $this->items[$k]; } +class Map { + /** @var array */ + private array $buckets = []; + public function set(K $k, V $v): void { $this->buckets[$k->hashCode()] = [$k, $v]; } + public function get(K $k): V { return $this->buckets[$k->hashCode()][1]; } } ``` +xphp **recognizes** the `\Hashable` bound (so `Map` and +`Set` compile and are bound-checked) but ships **no** runtime +`Hashable` interface — it's a pure transpiler. You (or your collection +library) provide the contract, e.g.: + +```php +interface Hashable { + public function hashCode(): int|string; + public function equals(self $other): bool; +} +``` + +Reference it fully-qualified (`\Hashable`) or via `use`, the same as the +built-in `\Stringable` bound. The deduping/keying logic itself is ordinary +runtime code in your container — the bound just gives it a type-checked +contract. + --- ## Duplicate generic template declaration diff --git a/src/Transpiler/Monomorphize/TypeHierarchy.php b/src/Transpiler/Monomorphize/TypeHierarchy.php index 8285163..4efa280 100644 --- a/src/Transpiler/Monomorphize/TypeHierarchy.php +++ b/src/Transpiler/Monomorphize/TypeHierarchy.php @@ -54,6 +54,12 @@ 'Error', 'BackedEnum', 'UnitEnum', + // Not a PHP-native interface: `Hashable` is the recognized value-equality + // bound (ticket 0006) so `Set` / `Map` are + // expressible and compile-time-checked. xphp ships no runtime `Hashable` + // — the consumer (or their collection library) provides the interface + // contract (`hashCode(): int|string`, `equals(self): bool`). + 'Hashable', ]; /** diff --git a/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php b/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php index 9c8b0cb..fbeda9f 100644 --- a/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php +++ b/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php @@ -57,6 +57,84 @@ public function testBoundIsSatisfiedByImplementingClass(): void self::assertGreaterThan(0, $result->generatedCount); } + public function testHashableBoundIsSatisfiedByImplementingClass(): void + { + // `Hashable` is a whitelisted bound name (ticket 0006). xphp recognizes it + // even though the interface is provided by the consumer/library and isn't + // in the scanned source set here — so `Set` resolves against a + // class that `implements Hashable`. + $sourceDir = $this->workDir . '/src'; + mkdir($sourceDir, 0o755, true); + $setFile = $sourceDir . '/Set.xphp'; + file_put_contents($setFile, <<<'PHP' + + { + private array $items = []; + public function add(T $x): void { $this->items[] = $x; } + } + PHP); + $userFile = $sourceDir . '/User.xphp'; + file_put_contents($userFile, <<<'PHP' + (); + PHP); + + $compiler = $this->buildCompiler(); + $sources = new FilepathArray($setFile, $userFile, $useFile); + $result = $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + + // Set specialized — the Hashable bound resolved (User implements it). + self::assertSame(1, $result->generatedCount); + } + + public function testHashableBoundViolationOnNonImplementingClass(): void + { + $sourceDir = $this->workDir . '/src'; + mkdir($sourceDir, 0o755, true); + $setFile = $sourceDir . '/Set.xphp'; + file_put_contents($setFile, <<<'PHP' + + { + public function add(T $x): void {} + } + PHP); + $plainFile = $sourceDir . '/Plain.xphp'; + file_put_contents($plainFile, <<<'PHP' + (); + PHP); + + $compiler = $this->buildCompiler(); + $sources = new FilepathArray($setFile, $plainFile, $useFile); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Generic bound violated'); + $this->expectExceptionMessage('Hashable'); + $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); + } + public function testBoundViolationOnScalarConcreteFailsCompilationWithClearMessage(): void { $sourceDir = $this->workDir . '/src'; diff --git a/test/Transpiler/Monomorphize/TypeHierarchyTest.php b/test/Transpiler/Monomorphize/TypeHierarchyTest.php index 2b6e37a..4d6a310 100644 --- a/test/Transpiler/Monomorphize/TypeHierarchyTest.php +++ b/test/Transpiler/Monomorphize/TypeHierarchyTest.php @@ -264,4 +264,19 @@ public function testAncestorChainNormalizesLeadingBackslash(): void self::assertSame(['App\\Sup'], $hierarchy->ancestorChain('\\App\\Sub')); } + + public function testHashableIsAWhitelistedBoundName(): void + { + // `Hashable` is a recognized bound name even though it isn't a PHP built-in + // and isn't declared in the source set (ticket 0006) — a class implementing + // it satisfies the bound; a known class that doesn't is rejected. + $hierarchy = new TypeHierarchy([ + 'App\\User' => ['Hashable'], + 'App\\Plain' => [], + ]); + + self::assertTrue($hierarchy->isDeclared('Hashable')); + self::assertTrue($hierarchy->isSubtype('App\\User', 'Hashable')); + self::assertFalse($hierarchy->isSubtype('App\\Plain', 'Hashable')); + } } From f09e2c3882cee8ee40ef364c28c0f54635c1f6de Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 19 Jun 2026 15:08:08 +0000 Subject: [PATCH 042/114] docs: document covariant typed-construction + Hashable bound; changelog variance.md: update the position table (a plain constructor parameter now accepts `+T`/`-T`, emitted variance-erased; promoted ctor params stay properties/invariant), add a "covariant immutable collections (typed construction)" section with the not-runtime-checked caveat. CHANGELOG: record the variant typed-constructor and the `Hashable` bound under Added. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 13 ++++++++ docs/caveats.md | 1 - docs/syntax/variance.md | 72 +++++++++++++++++++++++++++++++---------- 3 files changed, 68 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 079a1e1..2a0a37c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `phpstan/phpstan` stays optional and is never bundled in the PHAR; a missing binary or a failed run is a non-failing Warning. Opt out with `--no-phpstan`; override discovery with `--phpstan-bin` / `--phpstan-config`. +- **Type-parameter-typed constructors on variant classes.** A covariant / + contravariant class may now take its type parameter in a (non-promoted) + constructor parameter — e.g. a covariant immutable `ImmutableList<+T>` built + from `T ...$items`. The parameter is emitted variance-erased (the bound, else + `mixed`) so every specialisation's `__construct` is LSP-compatible across the + variance `extends` edge. Construction is accepted but not yet runtime- or + call-site-type-checked; promoted constructor params remain properties (strictly + invariant). See [variance](docs/syntax/variance.md). +- **`Hashable` value-equality bound.** `Set` and + `Map` are now expressible and compile-time bound-checked + (referenced fully-qualified, like `\Stringable`). xphp ships no runtime + `Hashable`; you or your collection library provide the contract + (`hashCode(): int|string`, `equals(self): bool`). See [caveats](docs/caveats.md). ### Fixed diff --git a/docs/caveats.md b/docs/caveats.md index 591b6db..4e1bb8a 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -405,7 +405,6 @@ parameter on `\Hashable`, the recognized value-equality bound, and key on ```php class Map { - /** @var array */ private array $buckets = []; public function set(K $k, V $v): void { $this->buckets[$k->hashCode()] = [$k, $v]; } public function get(K $k): V { return $this->buckets[$k->hashCode()][1]; } diff --git a/docs/syntax/variance.md b/docs/syntax/variance.md index 237079c..228cfe1 100644 --- a/docs/syntax/variance.md +++ b/docs/syntax/variance.md @@ -69,23 +69,61 @@ For contravariant `-T`, the edge flips: `Consumer extends Consumer`-style collection: + +```php +final class ImmutableList<+T> { + private array $items; + public function __construct(T ...$items) { $this->items = $items; } + public function get(int $i): T { return $this->items[$i]; } +} + +function firstProduct(ImmutableList $items): Product { return $items->get(0); } + +// Covariance: an ImmutableList is accepted where ImmutableList +// is expected, because Book extends Product. +$books = new ImmutableList::(new Book(), new Book()); +$p = firstProduct($books); +``` + +The constructor parameter is emitted as `mixed ...$items` (or the bound) on +every specialisation, so `ImmutableList` can `extends ImmutableList` +without a PHP fatal. (`final` is preserved in your source; xphp drops it only on +the internal generated specialisations so the edge can land.) + +> ⚠️ **Construction is not runtime-type-checked.** Because the emitted +> constructor parameter is erased to `mixed`/the bound, PHP performs no runtime +> element-type check at construction, and the compiler does not yet statically +> check the supplied arguments at the call site. Covariance and the typed +> *source* surface hold; a stricter construction-time check is future work. ### Inner-template variance composition From d231faf0f9cdddc5b349addc9fff29956661c12b Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 19 Jun 2026 23:17:47 +0000 Subject: [PATCH 043/114] docs(adr): record variance-erased constructors and the class-level-variance boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0013 captures permitting a type-parameter-typed constructor on a variant class by emitting the parameter variance-erased (bound/`mixed`) so the `extends` chain stays LSP-safe — with the compile-time-best-effort construction trade-off and the `final`-strip corollary. ADR-0014 records, as a permanent design boundary, why variance is class-level only (function specializations have no stable class identity for a subtype edge). Index updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-variance-erased-constructor-parameters.md | 128 ++++++++++++++++++ ...4-variance-markers-are-class-level-only.md | 92 +++++++++++++ docs/adr/README.md | 2 + 3 files changed, 222 insertions(+) create mode 100644 docs/adr/0013-variance-erased-constructor-parameters.md create mode 100644 docs/adr/0014-variance-markers-are-class-level-only.md diff --git a/docs/adr/0013-variance-erased-constructor-parameters.md b/docs/adr/0013-variance-erased-constructor-parameters.md new file mode 100644 index 0000000..f372382 --- /dev/null +++ b/docs/adr/0013-variance-erased-constructor-parameters.md @@ -0,0 +1,128 @@ +# 13. Variance-erased constructor parameters + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +Declaration-site variance (`+T` / `-T`) lowers to real `extends` edges between +specializations: `Producer` actually extends `Producer` when `Banana` +extends `Fruit` and `T` is covariant. PHP enforces **invariant** constructor parameter +types across an `extends` chain — a subclass constructor whose parameter type differs +from its parent's is a fatal error at autoload. So a covariant class that takes its type +parameter in a constructor (`class ImmutableList<+T> { public function __construct(T ...$items) }`) +would emit `Producer::__construct(Banana ...)` extending `Producer::__construct(Fruit ...)` +— two different signatures across the edge — and PHP-fatal the moment both specializations load. + +The position rules therefore forbade a variance-marked type parameter in a constructor +parameter (and in any property). But a covariant immutable collection — Kotlin's +`List`, the backbone of an immutability-first collections library — fundamentally +wants `T`-typed construction input. The question: can a covariant/contravariant class +accept its type parameter in a constructor without the autoload fatal? + +## Decision Drivers + +- Support `T`-typed construction on a covariant/contravariant class (so the headline + variance feature and a type-checked construction *surface* coexist). +- Keep every specialization's emitted signature LSP-compatible across the `extends` chain + — no PHP fatal. +- Do not relax the cases that are genuinely unsafe (properties, where PHP's invariance is + unavoidable and a covariant property really would fatal). + +## Considered Options + +- **Keep forbidding `T` in a constructor** — status quo; a covariant collection can't take + typed construction input. +- **Permit it and emit a concrete `T`-typed constructor** — fatals at autoload (the very + problem above). +- **Permit `T` in a non-promoted constructor parameter and emit it variance-*erased*** — + the parameter type on every specialization is the type parameter's bound (if a single + non-generic type) else `mixed`, so all specializations' `__construct` signatures are + byte-identical and LSP-safe. +- **A compiler-recognized immutable factory** (`static from(T ...): self`) — the same + erasure idea behind a named constructor rather than `__construct`. + +## Decision Outcome + +Chosen: **permit a non-promoted constructor parameter to carry `+T` / `-T`, and emit it +variance-erased** (the bound if it is a single non-generic leaf, else `mixed`). Because the +erased type is identical on every specialization, the constructor signature is the same on +both ends of every variance edge, so PHP never fatals. Covariance (the runtime edge) and a +typed construction *source surface* are both preserved. + +A corollary falls out: a `final` class cannot be a parent in an `extends` edge, so **`final` +is stripped from variant-class specializations**. These are internal generated classes — +user code references the marker interface or the turbofish call site, never the generated +names — so dropping `final` there is invisible; it is preserved on invariant-class +specializations. + +The relaxation is deliberately narrow. **Properties stay strictly invariant** — including a +*promoted* constructor parameter, which is a property and would fatal across the edge. A +**non-bare** type-parameter in a constructor (`?T`, `Box`, `T|X`) is **not** erased +(erasure only applies to a bare single-segment type-parameter) and is still rejected, +because it would not be chain-identical and would fatal. The set of parameters the emitter +erases is exactly the set the validators let through — the variance-position check permits +all variances on a plain constructor parameter of a variant class, and the inner-variance +check rejects every non-erasable `T`-bearing shape that slips past it. + +### Consequences + +- Good: a covariant immutable collection can take `T`-typed construction input and remain + usable contravariantly through subtyping (`ImmutableList` where + `ImmutableList` is expected), with no autoload fatal. +- Trade-off: because the emitted parameter is erased to `mixed`/the bound, PHP performs **no + runtime element-type check** at construction, and the compiler does not (yet) statically + check the supplied arguments at the call site — so the typed construction boundary is + compile-time-best-effort, not a runtime guarantee. A stricter call-site check is a + separate, harder analysis left for later. +- Trade-off: the mutable case is unchanged — a `T`-typed property (mutable, `readonly`, or + promoted) is still rejected, because PHP's property invariance across the edge is + unavoidable. Users hand-roll a `mixed`/`array` backing field plus a covariant `get(): T`. + +### Confirmation + +The relaxation is in `VariancePositionValidator::checkMethod` (a plain constructor parameter +of a variant class is allowed at any variance; a promoted one stays invariant) and +`InnerVarianceValidator` (the erased bare-type-param constructor parameters are skipped; every +other `T`-bearing shape is still walked and rejected). The erasure and the `final`-strip are in +[`Specializer::specialize`](../../src/Transpiler/Monomorphize/Specializer.php). A runtime test +compiles a covariant `ImmutableList<+T>` with a `T`-typed constructor and asserts that +`ImmutableList` is usable where `ImmutableList` is expected with **no** autoload +fatal, alongside structural tests for the bounded, mixed-variance, contravariant, two-parameter, +and invariant-kept-`final` cases, and rejection tests for the non-erasable shapes. + +## Pros and Cons of the Options + +### Variance-erased non-promoted constructor parameter + +- Good: preserves both covariance and a typed construction surface; the erasure point is + localized to the specializer; no autoload fatal; mutable case untouched. +- Bad: construction is not runtime-type-checked (erased to `mixed`/bound); call-site + argument checking is deferred. + +### Concrete `T`-typed constructor + +- Good: would give a real runtime check. +- Bad: PHP-fatals at autoload across the variance `extends` chain — not viable. + +### Keep forbidding `T` in a constructor + +- Good: simplest; no change. +- Bad: a covariant immutable collection cannot take typed construction input at all. + +### Compiler-recognized factory + +- Good: same erasure benefit, expressed as a named constructor. +- Bad: a strictly weaker subset of the same mechanism with extra surface; less ergonomic + than `__construct`. + +## More Information + +- [ADR-0001](0001-monomorphization-over-type-erasure.md) — why specializations (and their + `extends` edges) exist at all. +- [Variance](../syntax/variance.md) — the position rules, the covariant-construction + pattern, and the not-runtime-checked caveat. [Caveats](../caveats.md). +- [`Specializer`](../../src/Transpiler/Monomorphize/Specializer.php), + [`VariancePositionValidator`](../../src/Transpiler/Monomorphize/VariancePositionValidator.php), + [`InnerVarianceValidator`](../../src/Transpiler/Monomorphize/InnerVarianceValidator.php). +- [ADR-0014](0014-variance-markers-are-class-level-only.md) — why variance stays at the + class level (the related boundary). diff --git a/docs/adr/0014-variance-markers-are-class-level-only.md b/docs/adr/0014-variance-markers-are-class-level-only.md new file mode 100644 index 0000000..1575ceb --- /dev/null +++ b/docs/adr/0014-variance-markers-are-class-level-only.md @@ -0,0 +1,92 @@ +# 14. Variance markers are class-level only + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +Declaration-site variance (`+T` / `-T`) is realized as real `extends` edges between +*specialized classes*: `Producer` extends `Producer`, and PHP's native type +system carries the subtype relationship. That mechanism needs a stable, nominal class +identity at each end of the edge. + +Method-, function-, closure-, and arrow-scoped generics don't have one. Their +specializations are *functions* — mangled methods appended to a class, or top-level +functions, keyed by a call-site hash — not classes that can sit in an `extends` chain. So +the question is what to do when a type parameter on one of those carries a variance marker +(`function map<+U>(...)`, `$f = fn<-T>(...) => ...`): support it somehow, ignore it, or +reject it. + +## Decision Drivers + +- Variance must stay sound — a declared variance that isn't actually enforced is worse than + no variance. +- Don't advertise a feature the architecture can't honor without a disproportionate change. +- Give library authors a clear, stable answer so they don't design around a feature that + isn't coming. + +## Considered Options + +- **Implement method-level variance** — synthesize some stable identity for function + specializations so a subtype relationship can be expressed. Large, and there is no natural + PHP construct for "one function is a subtype of another." +- **Accept the markers and ignore them** — parse `+U` / `-U` on a function-scoped generic + but emit nothing. Silently unsound: the declared variance would have no effect. +- **Reject them at parse time as a permanent boundary** — and document the rationale. + +## Decision Outcome + +Chosen: **reject variance markers on method / function / closure / arrow type parameters at +parse time, as a permanent design boundary.** Variance is a class-level-only feature. The +error states plainly that this is by design (a function or closure specialization has no +stable class identity to anchor a subtype `extends` edge to), and points the author at the +class-level workaround. + +This is not a deferral. The cost of "implement it anyway" is high and the benefit is low: +the functional collection surface (`map`, `flatMap`, `groupBy`, …) works correctly +as *invariant* method generics, which matches Kotlin — whose `fun map(...)` is likewise +invariant. Method-level variance would be showcase richness, not a capability gap that +blocks real code. + +### Consequences + +- Good: the variance model stays simple and sound — every variance marker maps to a real, + enforced `extends` edge between classes. +- Good: the boundary is explicit and documented, so library authors know to keep variance at + the class level rather than waiting on a function-level feature. +- Trade-off: the interesting functional surface (method/closure generics) can never + participate in variance; it stays invariant. Acceptable — it matches Kotlin and doesn't + block correctness. + +### Confirmation + +The rejection is a single parse-time gate in +[`XphpSourceParser`](../../src/Transpiler/Monomorphize/XphpSourceParser.php) (the +`allowVariance` path), uniform across methods, free functions, closures, and arrow +functions, and pinned by tests for all four shapes. The boundary is documented in +[Variance](../syntax/variance.md) and [Caveats](../caveats.md). + +## Pros and Cons of the Options + +### Reject at parse time (permanent boundary) + +- Good: sound, simple, explicit; cheap; a clear answer for library authors. +- Bad: no method-level variance (matches Kotlin; not a real blocker). + +### Implement method-level variance + +- Good: maximal expressiveness. +- Bad: requires inventing a stable identity for function specializations with no natural PHP + analogue; large change for a "showcase" benefit. + +### Accept and ignore the markers + +- Good: no parse error. +- Bad: silently unsound — a declared variance that does nothing. + +## More Information + +- [ADR-0001](0001-monomorphization-over-type-erasure.md) — specializations and their class + identities (or lack thereof for functions). +- [ADR-0013](0013-variance-erased-constructor-parameters.md) — the class-level variance + surface this boundary sits alongside. +- [Variance](../syntax/variance.md), [Caveats](../caveats.md). diff --git a/docs/adr/README.md b/docs/adr/README.md index 77844a7..2fb9dc9 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -30,3 +30,5 @@ should be added here as a new numbered file; copy | [0010](0010-undeclared-type-and-arity-validation.md) | Undeclared-type and arity validation | Accepted | | [0011](0011-phar-distribution.md) | PHAR distribution via Humbug Box | Accepted | | [0012](0012-engineering-quality-bar.md) | The engineering quality bar | Accepted | +| [0013](0013-variance-erased-constructor-parameters.md) | Variance-erased constructor parameters | Accepted | +| [0014](0014-variance-markers-are-class-level-only.md) | Variance markers are class-level only | Accepted | From 95833b676c917526fb894a4b2e0b76d28151b679 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 21 Jun 2026 11:05:33 +0000 Subject: [PATCH 044/114] chore: scrub internal ticket references from tracked comments Remove `ticket NNNN` references from src/ code comments, test comments, and verify fixtures. These pointed at internal, locally-gitignored tracking and should not appear in tracked artifacts. Comment text is otherwise unchanged; no behavior affected. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/Specializer.php | 2 +- src/Transpiler/Monomorphize/TypeHierarchy.php | 2 +- .../Transpiler/Monomorphize/BoundedGenericIntegrationTest.php | 2 +- .../Monomorphize/BuiltinInterfaceViaUseIntegrationTest.php | 2 +- test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php | 4 ++-- test/Transpiler/Monomorphize/TypeHierarchyTest.php | 2 +- test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php | 2 +- test/Transpiler/Monomorphize/VariancePositionPhaseTest.php | 2 +- .../generic_covariant_immutable_ctor/verify/runtime.php | 2 +- .../generic_method_through_inheritance/verify/runtime.php | 2 +- .../verify/runtime.php | 2 +- 11 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Transpiler/Monomorphize/Specializer.php b/src/Transpiler/Monomorphize/Specializer.php index 89154c0..55185fd 100644 --- a/src/Transpiler/Monomorphize/Specializer.php +++ b/src/Transpiler/Monomorphize/Specializer.php @@ -90,7 +90,7 @@ public function specialize(ClassLike $template, array $substitution, array $type // Re-type the recorded constructor params to their erased (bound / `mixed`) // form so every specialization's `__construct` signature is identical and // stays LSP-compatible across the variance `extends` edge (a concrete - // `T`-typed ctor would PHP-fatal at autoload — see ticket 0005). + // `T`-typed ctor would PHP-fatal at autoload). self::applyConstructorErasures($cloned, $ctorErasures); return $cloned; diff --git a/src/Transpiler/Monomorphize/TypeHierarchy.php b/src/Transpiler/Monomorphize/TypeHierarchy.php index 4efa280..3c1ddb7 100644 --- a/src/Transpiler/Monomorphize/TypeHierarchy.php +++ b/src/Transpiler/Monomorphize/TypeHierarchy.php @@ -55,7 +55,7 @@ 'BackedEnum', 'UnitEnum', // Not a PHP-native interface: `Hashable` is the recognized value-equality - // bound (ticket 0006) so `Set` / `Map` are + // bound so `Set` / `Map` are // expressible and compile-time-checked. xphp ships no runtime `Hashable` // — the consumer (or their collection library) provides the interface // contract (`hashCode(): int|string`, `equals(self): bool`). diff --git a/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php b/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php index fbeda9f..35d4223 100644 --- a/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php +++ b/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php @@ -59,7 +59,7 @@ public function testBoundIsSatisfiedByImplementingClass(): void public function testHashableBoundIsSatisfiedByImplementingClass(): void { - // `Hashable` is a whitelisted bound name (ticket 0006). xphp recognizes it + // `Hashable` is a whitelisted bound name. xphp recognizes it // even though the interface is provided by the consumer/library and isn't // in the scanned source set here — so `Set` resolves against a // class that `implements Hashable`. diff --git a/test/Transpiler/Monomorphize/BuiltinInterfaceViaUseIntegrationTest.php b/test/Transpiler/Monomorphize/BuiltinInterfaceViaUseIntegrationTest.php index 854ff4a..0981453 100644 --- a/test/Transpiler/Monomorphize/BuiltinInterfaceViaUseIntegrationTest.php +++ b/test/Transpiler/Monomorphize/BuiltinInterfaceViaUseIntegrationTest.php @@ -16,7 +16,7 @@ use XPHP\TestSupport\SnapshotHash; /** - * Regression coverage for ticket 0001: a generic that `extends`/`implements` a + * Regression coverage: a generic that `extends`/`implements` a * built-in interface — or instantiates / catches an imported class — via a bare * `use` import used to keep those bare names when relocated into the * `XPHP\Generated\...` namespace, where they resolved to a non-existent diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php index 18320ad..cab8d27 100644 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php @@ -1260,7 +1260,7 @@ public function makeParent(T $v): Parent_ { #[RunInSeparateProcess] public function testGenericMethodResolvesThroughInheritance(): void { - // Ticket 0003: a generic method declared on a base class resolves and + // A generic method declared on a base class resolves and // runs when called via turbofish on a subclass receiver. The // specialization is emitted onto the DECLARING base so every subclass // inherits the single copy through the class-level `extends` edge -- @@ -1308,7 +1308,7 @@ public function testGenericMethodResolvesThroughInheritance(): void #[RunInSeparateProcess] public function testStaticGenericMethodResolvesThroughInheritance(): void { - // Ticket 0003 (static path): a static generic method declared on Base + // Static path: a static generic method declared on Base // resolves and runs when called as `Derived::make::<...>()`. The // specialization is emitted onto the declaring Base and reached via // PHP's static-method inheritance. diff --git a/test/Transpiler/Monomorphize/TypeHierarchyTest.php b/test/Transpiler/Monomorphize/TypeHierarchyTest.php index 4d6a310..c746cb5 100644 --- a/test/Transpiler/Monomorphize/TypeHierarchyTest.php +++ b/test/Transpiler/Monomorphize/TypeHierarchyTest.php @@ -268,7 +268,7 @@ public function testAncestorChainNormalizesLeadingBackslash(): void public function testHashableIsAWhitelistedBoundName(): void { // `Hashable` is a recognized bound name even though it isn't a PHP built-in - // and isn't declared in the source set (ticket 0006) — a class implementing + // and isn't declared in the source set — a class implementing // it satisfies the bound; a known class that doesn't is rejected. $hierarchy = new TypeHierarchy([ 'App\\User' => ['Hashable'], diff --git a/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php b/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php index 5e9c5f5..16ed95f 100644 --- a/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php +++ b/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php @@ -39,7 +39,7 @@ protected function tearDown(): void #[RunInSeparateProcess] public function testCovariantImmutableCollectionTakesTypedConstructorInput(): void { - // Ticket 0005: a covariant immutable collection `ImmutableList<+T>` with a + // A covariant immutable collection `ImmutableList<+T>` with a // `T`-typed constructor. The ctor param is emitted variance-erased (`mixed`) // so `ImmutableList` extends `ImmutableList` with NO PHP // autoload fatal, and a Banana list is usable where a Fruit list is expected. diff --git a/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php b/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php index baa91ec..57c4bbd 100644 --- a/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php +++ b/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php @@ -45,7 +45,7 @@ public static function rejectedSources(): iterable ['bound'], ]; // NOTE: `+T` in a *non-promoted* constructor parameter of a variant class is - // now ALLOWED (ticket 0005) — it is emitted variance-erased, so it's no longer + // now ALLOWED — it is emitted variance-erased, so it's no longer // a variance-position violation. See VarianceEdgeIntegrationTest's covariant // immutable-collection test. A *promoted* ctor param is a PROPERTY, which stays // strictly invariant (a `T`-typed property would PHP-fatal across the chain): diff --git a/test/fixture/compile/generic_covariant_immutable_ctor/verify/runtime.php b/test/fixture/compile/generic_covariant_immutable_ctor/verify/runtime.php index 17f984e..7ee979b 100644 --- a/test/fixture/compile/generic_covariant_immutable_ctor/verify/runtime.php +++ b/test/fixture/compile/generic_covariant_immutable_ctor/verify/runtime.php @@ -3,7 +3,7 @@ declare(strict_types=1); /** - * Runtime verify for `generic_covariant_immutable_ctor` (ticket 0005): + * Runtime verify for `generic_covariant_immutable_ctor`: * a covariant immutable collection `ImmutableList<+T>` with a `T`-typed * constructor. `ImmutableList` is usable where `ImmutableList` * is expected (covariant `extends` edge), and the variance-erased `mixed` diff --git a/test/fixture/compile/generic_method_through_inheritance/verify/runtime.php b/test/fixture/compile/generic_method_through_inheritance/verify/runtime.php index 840c807..a275936 100644 --- a/test/fixture/compile/generic_method_through_inheritance/verify/runtime.php +++ b/test/fixture/compile/generic_method_through_inheritance/verify/runtime.php @@ -3,7 +3,7 @@ declare(strict_types=1); /** - * Runtime verify for `generic_method_through_inheritance` (ticket 0003): + * Runtime verify for `generic_method_through_inheritance`: * a generic method (`identity`) declared on the base class `Base` * resolves and runs when called via turbofish on a `Derived` subclass * receiver. The specialization is emitted onto the declaring base and diff --git a/test/fixture/compile/generic_static_method_through_inheritance/verify/runtime.php b/test/fixture/compile/generic_static_method_through_inheritance/verify/runtime.php index 0256913..6c71aee 100644 --- a/test/fixture/compile/generic_static_method_through_inheritance/verify/runtime.php +++ b/test/fixture/compile/generic_static_method_through_inheritance/verify/runtime.php @@ -3,7 +3,7 @@ declare(strict_types=1); /** - * Runtime verify for `generic_static_method_through_inheritance` (ticket 0003): + * Runtime verify for `generic_static_method_through_inheritance`: * a static generic method (`make`) declared on `Base` resolves and runs when * called as `Derived::make::<...>()` on a subclass. The specialization is emitted * onto Base and reached through PHP's static-method inheritance. From 907f1067d58610b0f823128d16c9c07447999cf0 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 21 Jun 2026 13:41:54 +0000 Subject: [PATCH 045/114] feat(monomorphize)!: emit real types for variant constructor params (no erasure) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP exempts `__construct` from LSP signature checks, so a variance-marked constructor parameter does not need to be erased to keep the `extends` chain valid. Stop erasing: a `+T` / `-T` constructor parameter now keeps its real substituted type on every specialization (`Banana ...$items`, not `mixed`), giving a genuine runtime type check at construction while the covariant/contravariant `extends` edge still loads without a fatal. This removes Specializer::variantConstructorErasures / applyConstructorErasures / findConstructor; the `final`-strip and the variance edge emitter are unchanged. Constructor parameters are variance-position-exempt (a constructor isn't reached through an upcast reference — matching Kotlin); `T`-typed properties (mutable, readonly, or promoted) remain rejected because PHP property types are invariant across an edge. Tests now assert the real emitted ctor types and add empirical autoload + construction proofs for both the covariant (TypeError on a wrong element) and contravariant directions. BREAKING CHANGE: a variant class constructor parameter now emits its concrete element type instead of `mixed`/the bound. Co-Authored-By: Claude Opus 4.8 (1M context) --- infection.json5 | 22 +--- .../Monomorphize/InnerVarianceValidator.php | 24 ++-- src/Transpiler/Monomorphize/Specializer.php | 111 ++-------------- .../VariancePositionValidator.php | 10 +- .../VarianceEdgeIntegrationTest.php | 120 +++++++++++++----- .../VariancePositionPhaseTest.php | 5 +- .../source/ImmutableList.xphp | 7 +- .../verify/runtime.php | 27 +++- 8 files changed, 160 insertions(+), 166 deletions(-) diff --git a/infection.json5 b/infection.json5 index 71322c9..05c9986 100644 --- a/infection.json5 +++ b/infection.json5 @@ -296,16 +296,12 @@ "XPHP\\Transpiler\\Monomorphize\\Specializer::specialize", "XPHP\\Transpiler\\Monomorphize\\Specializer::specializeMethod", "XPHP\\Transpiler\\Monomorphize\\Specializer::specializeFunction", - // Specializer::applyConstructorErasures: the `if ($erasures === []) return;` - // fast-exit. Dropping it falls through to findConstructor + an empty foreach - // (a no-op), producing identical output. - "XPHP\\Transpiler\\Monomorphize\\Specializer::applyConstructorErasures", - // InnerVarianceValidator::isErasedVariantCtorParam: the promoted-param + // InnerVarianceValidator::isExemptVariantCtorParam: the promoted-param // `return false` guard is unreachable in practice — a covariant class with a // promoted (property) type-param is flagged by the variance-POSITION pass // first, and inner-variance skips position-flagged definitions, so this branch // never decides anything observable. - "XPHP\\Transpiler\\Monomorphize\\InnerVarianceValidator::isErasedVariantCtorParam", + "XPHP\\Transpiler\\Monomorphize\\InnerVarianceValidator::isExemptVariantCtorParam", // Specializer::substituteTypeRef: dropping `return $ref` on the empty-args // branch falls through to array_map over empty + ctor, producing an // equivalent TypeRef. Pure micro-optimisation, no observable difference. @@ -397,12 +393,7 @@ // just above). Negating both sub-exprs yields `!A || !B`, which is // ALWAYS true because $bound can't be both operand types at once -- // the mutated assert can never fail. Observationally equivalent. - "XPHP\\Transpiler\\Monomorphize\\VariancePositionValidator::checkBoundExpr", - // Specializer::applyConstructorErasures: the `assert($erased instanceof - // Identifier || $erased instanceof Name)` type-narrowing guard. typeRefToNode - // only ever returns one of those two for an erased ref, so negating the - // sub-exprs yields an assert that still holds — observationally equivalent. - "XPHP\\Transpiler\\Monomorphize\\Specializer::applyConstructorErasures" + "XPHP\\Transpiler\\Monomorphize\\VariancePositionValidator::checkBoundExpr" ] }, @@ -651,12 +642,7 @@ // before this optimization). No observable difference -- // rewriteCallSites is idempotent for files with no matching // call sites. - "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler", - // Specializer::variantConstructorErasures: the `mixed` erasure ref - // `new TypeRef('mixed', [], true, false)` — the final `false` is isTypeParam. - // typeRefToNode branches on isScalar (true) first and emits Identifier('mixed') - // regardless of isTypeParam, so toggling it to `true` is unobservable. - "XPHP\\Transpiler\\Monomorphize\\Specializer::variantConstructorErasures" + "XPHP\\Transpiler\\Monomorphize\\GenericMethodCompiler" ] }, // XphpSourceParser::applyReplacements: dropping the usort() entirely. diff --git a/src/Transpiler/Monomorphize/InnerVarianceValidator.php b/src/Transpiler/Monomorphize/InnerVarianceValidator.php index 9775373..651451d 100644 --- a/src/Transpiler/Monomorphize/InnerVarianceValidator.php +++ b/src/Transpiler/Monomorphize/InnerVarianceValidator.php @@ -112,12 +112,13 @@ private function collect(GenericDefinition $definition): void // so each promoted property is walked exactly once. // // Exception: a non-promoted ctor param typed by a bare - // covariant/contravariant type-param is emitted variance-erased - // (`mixed`/bound) by the Specializer, so its declared variance is - // irrelevant — skip it. Inner-generic ctor params (e.g. - // `Container`) are NOT erased and stay checked, as do promoted - // params (they're properties). - if ($isCtor && $this->isErasedVariantCtorParam($param)) { + // covariant/contravariant type-param is allowed — a constructor + // parameter isn't part of the externally-visible variance surface + // (constructors aren't called through upcast references), and PHP + // exempts `__construct` from LSP, so the real type is emitted with + // no hazard. Skip it. Inner-generic ctor params (e.g. `Container`) + // are still checked, as are promoted params (they're properties). + if ($isCtor && $this->isExemptVariantCtorParam($param)) { continue; } $outerPos = $isCtor ? Variance::Invariant : Variance::Contravariant; @@ -146,11 +147,12 @@ private function collect(GenericDefinition $definition): void /** * A non-promoted constructor parameter whose type is a bare single-segment - * covariant/contravariant type-param — exactly the params the Specializer - * emits variance-erased. Their declared variance no longer reaches the - * emitted signature, so the inner-variance walk skips them. + * covariant/contravariant type-param. Constructor parameters are exempt from + * variance-position checks (a constructor isn't part of the visible variance + * surface, and PHP exempts `__construct` from LSP), so the inner-variance walk + * skips these — the real type is emitted as-is. */ - private function isErasedVariantCtorParam(Param $param): bool + private function isExemptVariantCtorParam(Param $param): bool { if ($param->flags !== 0) { return false; // promoted param == property; stays strictly invariant. @@ -161,7 +163,7 @@ private function isErasedVariantCtorParam(Param $param): bool } $parts = $type->getParts(); if (count($parts) !== 1) { - return false; // inner-generic / qualified type — not erased, keep checking. + return false; // inner-generic / qualified type — not a bare type-param, keep checking. } $variance = $this->varianceMap[$parts[0]] ?? null; return $variance !== null && $variance !== Variance::Invariant; diff --git a/src/Transpiler/Monomorphize/Specializer.php b/src/Transpiler/Monomorphize/Specializer.php index 55185fd..562fca4 100644 --- a/src/Transpiler/Monomorphize/Specializer.php +++ b/src/Transpiler/Monomorphize/Specializer.php @@ -9,7 +9,6 @@ use PhpParser\Node\Name; use PhpParser\Modifiers; use PhpParser\Node\Name\FullyQualified; -use PhpParser\Node\Param; use PhpParser\Node\Stmt\Class_; use PhpParser\Node\Stmt\ClassLike; use PhpParser\Node\Stmt\ClassMethod; @@ -42,10 +41,20 @@ final class Specializer { /** * @param array $substitution Type-param name → concrete TypeRef. - * @param list $typeParams The template's type-params, used to - * variance-erase constructor parameters typed by a covariant/contravariant - * `T`. Empty (the default) disables erasure — callers that don't have the - * params, e.g. unit tests, get the plain substitution. + * @param list $typeParams The template's type-params. Used only to + * detect whether the class is variant, so `final` can be stripped from its + * specializations (a `final` parent can't anchor a variance `extends` edge). + * Empty (the default) keeps `final` — fine for callers, e.g. unit tests, that + * don't have the params. + * + * Type parameters in every position — including constructor parameters — are + * substituted to their *concrete* type; nothing is erased. PHP exempts + * `__construct` from LSP signature checks, so a `T`-typed constructor parameter + * specializes to its real type (`Banana ...$items`) and stays valid across the + * variance `extends` chain, giving a real runtime type check at construction. + * A `T`-typed *property* (mutable, readonly, or promoted) is the one shape that + * can't cross the edge — PHP property types are invariant — and is rejected + * upstream by the variance-position validator, not erased here. * * The cloned class's `name` is intentionally NOT set here — SpecializedClassGenerator::emit * is the single source of truth for the final shortname (derived from the generated FQCN). @@ -54,10 +63,6 @@ public function specialize(ClassLike $template, array $substitution, array $type { $originalTemplateFqn = $template->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); - // Record which constructor parameters must be variance-erased BEFORE the - // substituting visitor rewrites their `T` type to the concrete type. - $ctorErasures = self::variantConstructorErasures($template, $typeParams); - $cloned = self::deepClone($template); assert($cloned instanceof ClassLike); $cloned->setAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS, null); @@ -87,87 +92,9 @@ public function specialize(ClassLike $template, array $substitution, array $type self::runSubstitutingVisitor($cloned, $substitution); - // Re-type the recorded constructor params to their erased (bound / `mixed`) - // form so every specialization's `__construct` signature is identical and - // stays LSP-compatible across the variance `extends` edge (a concrete - // `T`-typed ctor would PHP-fatal at autoload). - self::applyConstructorErasures($cloned, $ctorErasures); - return $cloned; } - /** - * For a variant template, find the NON-promoted `__construct` parameters typed - * by a covariant/contravariant type-param and compute the type to emit instead - * of the concrete one: the param's bound when it's a single non-generic leaf, - * else `mixed`. Promoted params are skipped — they are properties and stay - * strictly invariant (rejected upstream by the variance validator). - * - * @param list $typeParams - * @return array constructor-parameter index → erased TypeRef - */ - private static function variantConstructorErasures(ClassLike $template, array $typeParams): array - { - $erasedByName = []; - foreach ($typeParams as $typeParam) { - if ($typeParam->variance === Variance::Invariant) { - continue; - } - $erasedByName[$typeParam->name] = - ($typeParam->bound instanceof BoundLeaf && !$typeParam->bound->type->isGeneric()) - ? $typeParam->bound->type - : new TypeRef('mixed', [], true, false); - } - if ($erasedByName === []) { - return []; - } - - $ctor = self::findConstructor($template); - if ($ctor === null) { - return []; - } - - $erasures = []; - foreach ($ctor->params as $i => $param) { - if ($param->flags !== 0) { - continue; // promoted params are properties — left invariant/rejected. - } - $type = $param->type; - if ($type instanceof Name && count($type->getParts()) === 1) { - $name = $type->getParts()[0]; - if (isset($erasedByName[$name])) { - $erasures[$i] = $erasedByName[$name]; - } - } - } - return $erasures; - } - - /** - * @param array $erasures constructor-parameter index → erased TypeRef - */ - private static function applyConstructorErasures(ClassLike $cloned, array $erasures): void - { - if ($erasures === []) { - return; - } - $ctor = self::findConstructor($cloned); - if ($ctor === null) { - return; - } - foreach ($erasures as $i => $erasedRef) { - $param = $ctor->params[$i] ?? null; - if ($param instanceof Param) { - // The erased type is a fresh synthetic node (`mixed` or a bound), so - // no source-position attributes are carried over. typeRefToNode returns - // an Identifier or a (Fully)Qualified Name — both valid for Param::$type. - $erased = self::typeRefToNode($erasedRef, []); - assert($erased instanceof Identifier || $erased instanceof Name); - $param->type = $erased; - } - } - } - /** @param list $typeParams */ private static function hasVariantParam(array $typeParams): bool { @@ -179,16 +106,6 @@ private static function hasVariantParam(array $typeParams): bool return false; } - private static function findConstructor(ClassLike $node): ?ClassMethod - { - foreach ($node->getMethods() as $method) { - if ($method->name->toLowerString() === '__construct') { - return $method; - } - } - return null; - } - /** * Specialize a single generic method: clone the ClassMethod, substitute every * Name reference to a type-param with the matching concrete TypeRef, drop the diff --git a/src/Transpiler/Monomorphize/VariancePositionValidator.php b/src/Transpiler/Monomorphize/VariancePositionValidator.php index bd76eba..9974ed5 100644 --- a/src/Transpiler/Monomorphize/VariancePositionValidator.php +++ b/src/Transpiler/Monomorphize/VariancePositionValidator.php @@ -206,10 +206,12 @@ private function checkMethod(ClassMethod $method): void : [Variance::Invariant, Variance::Contravariant]; $paramPosition = $isConstructor ? 'constructor parameter' : 'method parameter'; // A variant class (≥1 covariant/contravariant type-param) may carry its - // type-param in a NON-promoted constructor parameter: the specializer emits - // that parameter variance-erased (bound or `mixed`), so its signature is - // identical across the `extends` chain and LSP-safe. A *promoted* ctor - // param is also a property, which stays strictly invariant (a `T`-typed + // type-param in a NON-promoted constructor parameter at any variance: a + // constructor parameter is not part of the externally-visible variance + // surface (you can't call a constructor through an upcast reference), and + // PHP exempts `__construct` from LSP, so the specializer emits the real + // substituted type there with no soundness or autoload hazard. A *promoted* + // ctor param is also a property, which stays strictly invariant (a `T`-typed // property would PHP-fatal across the chain regardless). $classIsVariant = $this->varianceByName !== []; foreach ($method->params as $param) { diff --git a/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php b/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php index 16ed95f..95369c4 100644 --- a/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php +++ b/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php @@ -39,10 +39,12 @@ protected function tearDown(): void #[RunInSeparateProcess] public function testCovariantImmutableCollectionTakesTypedConstructorInput(): void { - // A covariant immutable collection `ImmutableList<+T>` with a - // `T`-typed constructor. The ctor param is emitted variance-erased (`mixed`) - // so `ImmutableList` extends `ImmutableList` with NO PHP - // autoload fatal, and a Banana list is usable where a Fruit list is expected. + // A covariant immutable collection `ImmutableList<+T>` with a `T`-typed + // constructor. The ctor param keeps its REAL element type on each + // specialization (`Fruit ...` / `Banana ...`) — PHP exempts `__construct` + // from LSP, so `ImmutableList` extends `ImmutableList` with + // NO autoload fatal, a Banana list is usable where a Fruit list is + // expected, and construction is runtime-type-checked. $fixture = CompiledFixture::compile( __DIR__ . '/../../fixture/compile/generic_covariant_immutable_ctor/source', 'variance-covariant-ctor', @@ -62,12 +64,18 @@ public function testCovariantImmutableCollectionTakesTypedConstructorInput(): vo $extendsEdges++; } } - // Both specializations emit the variance-erased `mixed` constructor. + // Each specialization keeps its REAL element type in the constructor. self::assertSame( - 2, - preg_match_all('/function __construct\(mixed \.\.\.\$items\)/', $combined), - 'both ctors variance-erased to `mixed ...$items`', + 1, + preg_match_all('/function __construct\(\\\\App\\\\CovariantCtor\\\\Fruit \.\.\.\$items\)/', $combined), + 'Fruit specialization ctor keeps `Fruit ...$items`', ); + self::assertSame( + 1, + preg_match_all('/function __construct\(\\\\App\\\\CovariantCtor\\\\Banana \.\.\.\$items\)/', $combined), + 'Banana specialization ctor keeps `Banana ...$items`', + ); + self::assertStringNotContainsString('mixed ...$items', $combined, 'nothing is erased to mixed'); // Exactly one specialization extends the other — the covariant edge. self::assertSame(1, $extendsEdges, 'ImmutableList extends ImmutableList'); // `final` is stripped from variant-class specializations so the edge's @@ -81,55 +89,59 @@ public function testCovariantImmutableCollectionTakesTypedConstructorInput(): vo } } - public function testBoundedCovariantConstructorErasesToTheBound(): void + public function testBoundedCovariantConstructorKeepsConcreteType(): void { - // A bounded covariant ctor param erases to the BOUND (not `mixed`), so the - // emitted signature stays chain-identical AND keeps a coarse runtime check. + // A bounded covariant ctor param keeps its REAL substituted type (the + // concrete arg, not the bound and not `mixed`) — constructors are LSP-exempt. $generated = $this->compileInlineAndReadGenerated([ 'Box.xphp' => "\n{\n private array \$items;\n public function __construct(T ...\$items) { \$this->items = \$items; }\n public function get(int \$i): T { return \$this->items[\$i]; }\n}\n", 'Tag.xphp' => " "(new Tag());\n", ]); - self::assertStringContainsString('__construct(\\Stringable ...$items)', $generated); + self::assertStringContainsString('__construct(\\App\\BoundCtor\\Tag ...$items)', $generated); self::assertStringNotContainsString('__construct(mixed', $generated); + self::assertStringNotContainsString('__construct(\\Stringable', $generated); } - public function testMixedVarianceConstructorErasesOnlyTheVariantParam(): void + public function testMixedVarianceConstructorKeepsConcreteTypes(): void { - // `Pair<+A, B>`: the covariant `A` ctor param erases to `mixed`; the - // invariant `B` param keeps its concrete substituted type; and a plain + // `Pair<+A, B>`: the covariant `A` ctor param keeps its concrete type, the + // invariant `B` param keeps its concrete substituted type, and a plain // scalar param (`int $tag`) is left untouched (it isn't a type-param). $generated = $this->compileInlineAndReadGenerated([ 'Pair.xphp' => "\n{\n private array \$slots;\n public function __construct(A \$a, B \$b, int \$tag) { \$this->slots = [\$a, \$b, \$tag]; }\n public function first(): A { return \$this->slots[0]; }\n}\n", 'Apple.xphp' => " "(new Apple(), new Apple(), 5);\n", ]); - self::assertMatchesRegularExpression('/__construct\(mixed \$a, \\\\App\\\\MixedCtor\\\\Apple \$b, int \$tag\)/', $generated); + self::assertMatchesRegularExpression('/__construct\(\\\\App\\\\MixedCtor\\\\Apple \$a, \\\\App\\\\MixedCtor\\\\Apple \$b, int \$tag\)/', $generated); + self::assertStringNotContainsString('mixed $a', $generated); } - public function testContravariantConstructorParamIsAlsoErased(): void + public function testContravariantConstructorParamKeepsConcreteType(): void { - // Symmetry with the covariant case: a `-T` ctor param erases to `mixed` + // Symmetry with the covariant case: a `-T` ctor param keeps its real type // too, and the contravariant edge (Consumer extends Consumer) - // stays LSP-safe with identical erased ctors. + // stays valid because constructors are LSP-exempt. $generated = $this->compileInlineAndReadGenerated([ 'Consumer.xphp' => "\n{\n private array \$items;\n public function __construct(T ...\$items) { \$this->items = \$items; }\n public function accept(T \$x): void { \$this->items[] = \$x; }\n}\n", 'Fruit.xphp' => " " "();\n\$b = new Consumer::();\n", ]); - self::assertSame(2, preg_match_all('/function __construct\(mixed \.\.\.\$items\)/', $generated)); + self::assertSame(1, preg_match_all('/function __construct\(\\\\App\\\\ContraCtor\\\\Banana \.\.\.\$items\)/', $generated)); + self::assertSame(1, preg_match_all('/function __construct\(\\\\App\\\\ContraCtor\\\\Fruit \.\.\.\$items\)/', $generated)); + self::assertStringNotContainsString('mixed ...$items', $generated); self::assertStringContainsString('extends \\XPHP\\Generated\\App\\ContraCtor\\Consumer\\T_', $generated); } - public function testNonErasableVariantConstructorParamsAreStillRejected(): void + public function testNonBareVariantConstructorParamShapesAreRejected(): void { - // Only a *bare* covariant type-param ctor param is variance-erased. These - // shapes are NOT erased (they'd PHP-fatal across the edge), so the - // inner-variance check must still reject them: + // Only a *bare* variance-marked type-param is currently supported in a + // constructor parameter. Richer shapes are still rejected by the + // inner-variance check (not yet supported in ctor position): // - `?T` — nullable, not a bare Name // - `Box` — T through another generic's invariant slot - // - `(T $a, ?T $b)` — the erased leading `T` must not stop the walk from + // - `(T $a, ?T $b)` — the allowed leading `T` must not stop the walk from // reaching the bad trailing `?T` $cases = [ "class P<+T>\n{\n public function __construct(?T \$x) {}\n}\n", @@ -141,16 +153,17 @@ public function testNonErasableVariantConstructorParamsAreStillRejected(): void } } - public function testTwoCovariantParamsBothEraseTheirConstructorParams(): void + public function testTwoCovariantParamsBothKeepConcreteTypes(): void { - // Two covariant params: BOTH `T`-typed ctor params erase to `mixed` - // (pins that erasure applies to every variant ctor param, not just one). + // Two covariant params: BOTH `T`-typed ctor params keep their real + // substituted types (pins that nothing is erased for any variant ctor param). $generated = $this->compileInlineAndReadGenerated([ 'Two.xphp' => "\n{\n private array \$slots;\n public function __construct(A \$a, B \$b) { \$this->slots = [\$a, \$b]; }\n public function getA(): A { return \$this->slots[0]; }\n public function getB(): B { return \$this->slots[1]; }\n}\n", 'Apple.xphp' => " "(new Apple(), new Apple());\n", ]); - self::assertSame(1, preg_match_all('/__construct\(mixed \$a, mixed \$b\)/', $generated)); + self::assertSame(1, preg_match_all('/__construct\(\\\\App\\\\TwoCtor\\\\Apple \$a, \\\\App\\\\TwoCtor\\\\Apple \$b\)/', $generated)); + self::assertStringNotContainsString('mixed $a', $generated); } public function testInvariantClassConstructorIsNotErasedAndKeepsFinal(): void @@ -273,6 +286,55 @@ public function testEmittedSubtypeChainAutoloadsWithoutPhpFatal(): void self::assertContains('OK', $output); } + public function testContravariantConstructorChainAutoloadsAndConstructsWithoutPhpFatal(): void + { + // The CONTRAVARIANT counterpart of the autoload proof, with a real-typed + // constructor. The edge flips: `Consumer` extends `Consumer`, + // so the child ctor (`Fruit ...$items`) WIDENS the parent's (`Banana ...`). + // PHP exempts `__construct` from LSP, so the chain must both autoload AND + // construct instances of each specialization without a fatal — empirically + // confirming the same exemption holds in the contravariant direction. + $src = $this->workDir . '/src-contra-auto'; + mkdir($src, 0o755, true); + file_put_contents($src . '/Consumer.xphp', "\n{\n private array \$items;\n public function __construct(T ...\$items) { \$this->items = \$items; }\n public function accept(T \$x): void { \$this->items[] = \$x; }\n}\n"); + file_put_contents($src . '/Fruit.xphp', "(new Banana());\n\$b = new Consumer::(new Fruit());\n"); + + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder())->find($src) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + $compiler->compile($sources, $src, $this->targetDir, $this->cacheDir); + + $bananaFqn = Registry::generatedFqn('App\\ContraAuto\\Consumer', [new TypeRef('App\\ContraAuto\\Banana')]); + $fruitFqn = Registry::generatedFqn('App\\ContraAuto\\Consumer', [new TypeRef('App\\ContraAuto\\Fruit')]); + $prefixes = [ + 'XPHP\\Generated\\' => $this->cacheDir . '/Generated', + 'App\\ContraAuto\\' => $this->targetDir, + ]; + + $loader = $this->workDir . '/contra-load.php'; + $script = " \$base) {\n" + . " if (str_starts_with(\$c, \$p)) {\n" + . " \$f = \$base . '/' . str_replace('\\\\', '/', substr(\$c, strlen(\$p))) . '.php';\n" + . " if (is_file(\$f)) { require_once \$f; }\n" + . " }\n" + . " }\n" + . "});\n" + . "new (" . var_export($bananaFqn, true) . ")(new \\App\\ContraAuto\\Banana());\n" + . "new (" . var_export($fruitFqn, true) . ")(new \\App\\ContraAuto\\Fruit());\n" + . "echo \"OK\\n\";\n"; + file_put_contents($loader, $script); + + $output = []; + $exitCode = 0; + exec('php ' . escapeshellarg($loader) . ' 2>&1', $output, $exitCode); + self::assertSame(0, $exitCode, "Contravariant ctor chain fataled:\n" . implode("\n", $output)); + self::assertContains('OK', $output); + } + public function testNoEdgeBetweenUnrelatedSpecializations(): void { // Banana and Apple both extend Fruit but not each other. The edge diff --git a/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php b/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php index 57c4bbd..ca8aefc 100644 --- a/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php +++ b/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php @@ -45,8 +45,9 @@ public static function rejectedSources(): iterable ['bound'], ]; // NOTE: `+T` in a *non-promoted* constructor parameter of a variant class is - // now ALLOWED — it is emitted variance-erased, so it's no longer - // a variance-position violation. See VarianceEdgeIntegrationTest's covariant + // ALLOWED — a constructor parameter is variance-exempt (constructors aren't + // called through upcast references, and PHP exempts `__construct` from LSP), so + // the real type is emitted there. See VarianceEdgeIntegrationTest's covariant // immutable-collection test. A *promoted* ctor param is a PROPERTY, which stays // strictly invariant (a `T`-typed property would PHP-fatal across the chain): yield 'covariant in promoted constructor property' => [ diff --git a/test/fixture/compile/generic_covariant_immutable_ctor/source/ImmutableList.xphp b/test/fixture/compile/generic_covariant_immutable_ctor/source/ImmutableList.xphp index 581e256..92549c9 100644 --- a/test/fixture/compile/generic_covariant_immutable_ctor/source/ImmutableList.xphp +++ b/test/fixture/compile/generic_covariant_immutable_ctor/source/ImmutableList.xphp @@ -5,9 +5,10 @@ declare(strict_types=1); namespace App\CovariantCtor; // Covariant immutable collection: `+T` with a `T`-typed constructor. The -// constructor parameter is emitted variance-erased (`mixed`) on every -// specialization so the `extends` chain (ImmutableList extends -// ImmutableList) stays LSP-safe. +// constructor parameter keeps its REAL type on every specialization +// (`Fruit ...` / `Banana ...`); PHP exempts `__construct` from LSP, so the +// `extends` chain (ImmutableList extends ImmutableList) is +// valid and construction is runtime-type-checked. final class ImmutableList<+T> { private array $items; diff --git a/test/fixture/compile/generic_covariant_immutable_ctor/verify/runtime.php b/test/fixture/compile/generic_covariant_immutable_ctor/verify/runtime.php index 7ee979b..a98542e 100644 --- a/test/fixture/compile/generic_covariant_immutable_ctor/verify/runtime.php +++ b/test/fixture/compile/generic_covariant_immutable_ctor/verify/runtime.php @@ -6,15 +6,38 @@ * Runtime verify for `generic_covariant_immutable_ctor`: * a covariant immutable collection `ImmutableList<+T>` with a `T`-typed * constructor. `ImmutableList` is usable where `ImmutableList` - * is expected (covariant `extends` edge), and the variance-erased `mixed` - * constructor does NOT PHP-fatal at autoload across that edge. + * is expected (covariant `extends` edge), the constructor keeps its REAL element + * type on each specialization (no erasure), and that real type is enforced by + * PHP at construction — passing a non-Banana to `ImmutableList` throws. * * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. */ +use App\CovariantCtor\Banana; +use App\CovariantCtor\Fruit; use PHPUnit\Framework\Assert; +use XPHP\Transpiler\Monomorphize\Registry; +use XPHP\Transpiler\Monomorphize\TypeRef; require $fixture->targetDir . '/Use.php'; Assert::assertSame(2, $cnt); Assert::assertSame('banana', $name); + +// The constructor element type is REAL (not erased to `mixed`) and runtime-checked: +// an `ImmutableList` rejects a plain `Fruit` at construction. +$bananaListFqn = Registry::generatedFqn( + 'App\\CovariantCtor\\ImmutableList', + [new TypeRef('App\\CovariantCtor\\Banana')], +); +$threw = false; +try { + new $bananaListFqn(new Fruit('apple')); +} catch (\TypeError) { + $threw = true; +} +Assert::assertTrue($threw, 'ImmutableList must reject a non-Banana element at construction'); + +// And it accepts a real Banana. +$ok = new $bananaListFqn(new Banana()); +Assert::assertSame('banana', $ok->get(0)->name); From 117ca3046db7f11aca7ed589dc73871a32fd4daf Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 21 Jun 2026 13:46:02 +0000 Subject: [PATCH 046/114] docs: variant constructor params are real-typed, not erased MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the docs to match the implementation: a `+T`/`-T` constructor parameter keeps its real element type and construction is runtime-type- checked (PHP exempts `__construct` from LSP), rather than being erased to `mixed`/the bound. - variance.md: position table (plain ctor param no longer "erased"), the rationale prose, and the construction note (now a ✅ runtime-checked guarantee, with the property-invariance limitation called out). - CHANGELOG: the typed-constructor entry reflects real-typed, runtime-checked construction. - ADR-0013 rewritten and renamed to "Typed constructor parameters on variant classes": corrects the false premise that PHP fatals on differing constructor signatures across an extends edge, and records the real-typed decision. Index + ADR-0014 cross-link updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 16 ++- ...nstructor-parameters-on-variant-classes.md | 113 ++++++++++++++++ ...-variance-erased-constructor-parameters.md | 128 ------------------ ...4-variance-markers-are-class-level-only.md | 4 +- docs/adr/README.md | 2 +- docs/syntax/variance.md | 41 +++--- 6 files changed, 149 insertions(+), 155 deletions(-) create mode 100644 docs/adr/0013-typed-constructor-parameters-on-variant-classes.md delete mode 100644 docs/adr/0013-variance-erased-constructor-parameters.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a0a37c..510cd63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,13 +24,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 binary or a failed run is a non-failing Warning. Opt out with `--no-phpstan`; override discovery with `--phpstan-bin` / `--phpstan-config`. - **Type-parameter-typed constructors on variant classes.** A covariant / - contravariant class may now take its type parameter in a (non-promoted) - constructor parameter — e.g. a covariant immutable `ImmutableList<+T>` built - from `T ...$items`. The parameter is emitted variance-erased (the bound, else - `mixed`) so every specialisation's `__construct` is LSP-compatible across the - variance `extends` edge. Construction is accepted but not yet runtime- or - call-site-type-checked; promoted constructor params remain properties (strictly - invariant). See [variance](docs/syntax/variance.md). + contravariant class may take its type parameter in a (non-promoted) constructor + parameter — e.g. a covariant immutable `ImmutableList<+T>` built from + `T ...$items`. The parameter keeps its **real** element type on every + specialisation (`Book ...$items`, not `mixed`), so construction is + **runtime-type-checked** while `ImmutableList` still extends + `ImmutableList` — PHP exempts `__construct` from LSP, so the + specialisations' constructors may differ across the edge. Promoted constructor + params remain properties (strictly invariant — PHP property types can't vary + across the edge). See [variance](docs/syntax/variance.md). - **`Hashable` value-equality bound.** `Set` and `Map` are now expressible and compile-time bound-checked (referenced fully-qualified, like `\Stringable`). xphp ships no runtime diff --git a/docs/adr/0013-typed-constructor-parameters-on-variant-classes.md b/docs/adr/0013-typed-constructor-parameters-on-variant-classes.md new file mode 100644 index 0000000..7ffb44e --- /dev/null +++ b/docs/adr/0013-typed-constructor-parameters-on-variant-classes.md @@ -0,0 +1,113 @@ +# 13. Typed constructor parameters on variant classes + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +Declaration-site variance (`+T` / `-T`) lowers to real `extends` edges between +specializations: `Producer` actually extends `Producer` when `Banana` +extends `Fruit` and `T` is covariant. A covariant immutable collection — Kotlin's +`List`, the backbone of an immutability-first collections library — wants to take +its element type as **construction input**: `class ImmutableList<+T> { public function +__construct(T ...$items) }`. The question is whether a variant class can accept its type +parameter in a constructor across the variance `extends` edge, and with what type. + +An earlier exploration assumed PHP enforces **invariant** constructor parameter types +across an `extends` chain — i.e. that `ImmutableList::__construct(Banana ...)` +extending `ImmutableList::__construct(Fruit ...)` would fatal at autoload — and so +proposed emitting the parameter *variance-erased* (to the bound, else `mixed`). **That +premise is false.** PHP exempts `__construct` from LSP signature-compatibility checks: a +child constructor may have a completely different parameter list from its parent's with no +error. (Verified empirically, both directions.) So no erasure is needed. + +## Decision Drivers + +- Let a covariant/contravariant class take `T`-typed construction input. +- Keep the type information real — a generic is worth most when the concrete type is + enforced. Avoid silently widening a declared `T` to `mixed`. +- Stay sound: a declared variance must not let an unsound subtype edge through. +- Don't fatal at autoload. + +## Considered Options + +- **Forbid `T` in a constructor** — status quo before this; a covariant collection can't + take typed construction input at all. +- **Emit the parameter variance-erased** (bound, else `mixed`) — chain-identical, but + based on the false fatal premise; throws away the real type and the runtime check. +- **Emit the parameter with its real substituted type** — relies on PHP's `__construct` + LSP exemption; keeps the real type and a runtime check. + +## Decision Outcome + +Chosen: **permit a non-promoted constructor parameter to carry `+T` / `-T`, emitted with +its real substituted type.** A constructor parameter is *variance-position-exempt* — a +constructor is never reached through an upcast reference, so it isn't part of the +externally-visible variance surface (the same reason Kotlin allows `out T` in a +constructor). And because PHP doesn't LSP-check `__construct` across the chain, the +specializations' constructors may legitimately differ (`Banana ...$items` on the child, +`Fruit ...$items` on the parent). The covariant edge holds, autoload is clean, **and +construction is runtime-type-checked** — building an `ImmutableList` from a +non-`Banana` throws a `TypeError`. + +The `final`-strip corollary stands: a `final` class can't be a parent in an `extends` +edge, so `final` is dropped from variant-class specializations (internal generated +classes; invisible to user code). + +The relaxation is narrow. **Properties stay strictly invariant** — mutable, `readonly`, +and *promoted* constructor parameters (which are properties). PHP makes property types +invariant across an `extends` chain (`Type of Child::$item must be …`), so a `T`-typed +property genuinely fatals — there is no way to carry a real `T` there. Such a property is +rejected at compile time (not erased); store elements in a plain `array`/`mixed` backing +field and expose them through a covariant `get(): T`. Non-bare shapes in a constructor +parameter (`?T`, `Box`, `T|X`) are not yet supported and stay rejected. + +### Consequences + +- Good: a covariant immutable collection takes `T`-typed construction input that is + enforced at runtime, remains usable covariantly (`ImmutableList` where + `ImmutableList` is expected), and never fatals at autoload. +- Good: nothing is erased — the declared type survives into the emitted signature. +- Trade-off: a `T`-typed property (mutable/`readonly`/promoted) is still rejected, because + PHP property invariance across the edge is unavoidable. Users hand-roll a `mixed`/`array` + backing field plus a covariant `get(): T`. +- Trade-off: richer constructor-parameter shapes (`?T`, `Box`, `T|X`) aren't supported + yet, only a bare variance-marked type parameter. + +### Confirmation + +`VariancePositionValidator::checkMethod` allows a plain constructor parameter of a variant +class at any variance (a promoted one stays invariant); `InnerVarianceValidator` skips a +bare variance-marked constructor parameter (`isExemptVariantCtorParam`) and still rejects +the non-bare shapes. [`Specializer::specialize`](../../src/Transpiler/Monomorphize/Specializer.php) +substitutes the real type into the constructor parameter — no erasure step. Tests compile a +covariant `ImmutableList<+T>` and assert each specialization's constructor keeps its real +element type, that the chain autoloads with **no** fatal, that an `ImmutableList` +**throws** on a non-`Banana` element, and that the contravariant constructor chain +autoloads and constructs equally cleanly. + +## Pros and Cons of the Options + +### Real-typed constructor parameter (chosen) + +- Good: keeps the real type and a runtime check; no autoload fatal; localized to the + specializer (just normal substitution). +- Bad: properties still can't carry a real `T`; non-bare ctor shapes not yet supported. + +### Variance-erased constructor parameter + +- Good: chain-identical signatures. +- Bad: rests on a false fatal premise; discards the real type and the runtime check for no + benefit. + +### Forbid `T` in a constructor + +- Good: simplest. +- Bad: a covariant collection can't take typed construction input at all. + +## More Information + +- [ADR-0001](0001-monomorphization-over-type-erasure.md) — why specializations (and their + `extends` edges) exist at all. +- [ADR-0014](0014-variance-markers-are-class-level-only.md) — why variance stays at the + class level (the related boundary). +- [Variance](../syntax/variance.md) — the position rules and the typed-construction pattern. diff --git a/docs/adr/0013-variance-erased-constructor-parameters.md b/docs/adr/0013-variance-erased-constructor-parameters.md deleted file mode 100644 index f372382..0000000 --- a/docs/adr/0013-variance-erased-constructor-parameters.md +++ /dev/null @@ -1,128 +0,0 @@ -# 13. Variance-erased constructor parameters - -- Status: Accepted — 2026-06 - -## Context and Problem Statement - -Declaration-site variance (`+T` / `-T`) lowers to real `extends` edges between -specializations: `Producer` actually extends `Producer` when `Banana` -extends `Fruit` and `T` is covariant. PHP enforces **invariant** constructor parameter -types across an `extends` chain — a subclass constructor whose parameter type differs -from its parent's is a fatal error at autoload. So a covariant class that takes its type -parameter in a constructor (`class ImmutableList<+T> { public function __construct(T ...$items) }`) -would emit `Producer::__construct(Banana ...)` extending `Producer::__construct(Fruit ...)` -— two different signatures across the edge — and PHP-fatal the moment both specializations load. - -The position rules therefore forbade a variance-marked type parameter in a constructor -parameter (and in any property). But a covariant immutable collection — Kotlin's -`List`, the backbone of an immutability-first collections library — fundamentally -wants `T`-typed construction input. The question: can a covariant/contravariant class -accept its type parameter in a constructor without the autoload fatal? - -## Decision Drivers - -- Support `T`-typed construction on a covariant/contravariant class (so the headline - variance feature and a type-checked construction *surface* coexist). -- Keep every specialization's emitted signature LSP-compatible across the `extends` chain - — no PHP fatal. -- Do not relax the cases that are genuinely unsafe (properties, where PHP's invariance is - unavoidable and a covariant property really would fatal). - -## Considered Options - -- **Keep forbidding `T` in a constructor** — status quo; a covariant collection can't take - typed construction input. -- **Permit it and emit a concrete `T`-typed constructor** — fatals at autoload (the very - problem above). -- **Permit `T` in a non-promoted constructor parameter and emit it variance-*erased*** — - the parameter type on every specialization is the type parameter's bound (if a single - non-generic type) else `mixed`, so all specializations' `__construct` signatures are - byte-identical and LSP-safe. -- **A compiler-recognized immutable factory** (`static from(T ...): self`) — the same - erasure idea behind a named constructor rather than `__construct`. - -## Decision Outcome - -Chosen: **permit a non-promoted constructor parameter to carry `+T` / `-T`, and emit it -variance-erased** (the bound if it is a single non-generic leaf, else `mixed`). Because the -erased type is identical on every specialization, the constructor signature is the same on -both ends of every variance edge, so PHP never fatals. Covariance (the runtime edge) and a -typed construction *source surface* are both preserved. - -A corollary falls out: a `final` class cannot be a parent in an `extends` edge, so **`final` -is stripped from variant-class specializations**. These are internal generated classes — -user code references the marker interface or the turbofish call site, never the generated -names — so dropping `final` there is invisible; it is preserved on invariant-class -specializations. - -The relaxation is deliberately narrow. **Properties stay strictly invariant** — including a -*promoted* constructor parameter, which is a property and would fatal across the edge. A -**non-bare** type-parameter in a constructor (`?T`, `Box`, `T|X`) is **not** erased -(erasure only applies to a bare single-segment type-parameter) and is still rejected, -because it would not be chain-identical and would fatal. The set of parameters the emitter -erases is exactly the set the validators let through — the variance-position check permits -all variances on a plain constructor parameter of a variant class, and the inner-variance -check rejects every non-erasable `T`-bearing shape that slips past it. - -### Consequences - -- Good: a covariant immutable collection can take `T`-typed construction input and remain - usable contravariantly through subtyping (`ImmutableList` where - `ImmutableList` is expected), with no autoload fatal. -- Trade-off: because the emitted parameter is erased to `mixed`/the bound, PHP performs **no - runtime element-type check** at construction, and the compiler does not (yet) statically - check the supplied arguments at the call site — so the typed construction boundary is - compile-time-best-effort, not a runtime guarantee. A stricter call-site check is a - separate, harder analysis left for later. -- Trade-off: the mutable case is unchanged — a `T`-typed property (mutable, `readonly`, or - promoted) is still rejected, because PHP's property invariance across the edge is - unavoidable. Users hand-roll a `mixed`/`array` backing field plus a covariant `get(): T`. - -### Confirmation - -The relaxation is in `VariancePositionValidator::checkMethod` (a plain constructor parameter -of a variant class is allowed at any variance; a promoted one stays invariant) and -`InnerVarianceValidator` (the erased bare-type-param constructor parameters are skipped; every -other `T`-bearing shape is still walked and rejected). The erasure and the `final`-strip are in -[`Specializer::specialize`](../../src/Transpiler/Monomorphize/Specializer.php). A runtime test -compiles a covariant `ImmutableList<+T>` with a `T`-typed constructor and asserts that -`ImmutableList` is usable where `ImmutableList` is expected with **no** autoload -fatal, alongside structural tests for the bounded, mixed-variance, contravariant, two-parameter, -and invariant-kept-`final` cases, and rejection tests for the non-erasable shapes. - -## Pros and Cons of the Options - -### Variance-erased non-promoted constructor parameter - -- Good: preserves both covariance and a typed construction surface; the erasure point is - localized to the specializer; no autoload fatal; mutable case untouched. -- Bad: construction is not runtime-type-checked (erased to `mixed`/bound); call-site - argument checking is deferred. - -### Concrete `T`-typed constructor - -- Good: would give a real runtime check. -- Bad: PHP-fatals at autoload across the variance `extends` chain — not viable. - -### Keep forbidding `T` in a constructor - -- Good: simplest; no change. -- Bad: a covariant immutable collection cannot take typed construction input at all. - -### Compiler-recognized factory - -- Good: same erasure benefit, expressed as a named constructor. -- Bad: a strictly weaker subset of the same mechanism with extra surface; less ergonomic - than `__construct`. - -## More Information - -- [ADR-0001](0001-monomorphization-over-type-erasure.md) — why specializations (and their - `extends` edges) exist at all. -- [Variance](../syntax/variance.md) — the position rules, the covariant-construction - pattern, and the not-runtime-checked caveat. [Caveats](../caveats.md). -- [`Specializer`](../../src/Transpiler/Monomorphize/Specializer.php), - [`VariancePositionValidator`](../../src/Transpiler/Monomorphize/VariancePositionValidator.php), - [`InnerVarianceValidator`](../../src/Transpiler/Monomorphize/InnerVarianceValidator.php). -- [ADR-0014](0014-variance-markers-are-class-level-only.md) — why variance stays at the - class level (the related boundary). diff --git a/docs/adr/0014-variance-markers-are-class-level-only.md b/docs/adr/0014-variance-markers-are-class-level-only.md index 1575ceb..f1fce93 100644 --- a/docs/adr/0014-variance-markers-are-class-level-only.md +++ b/docs/adr/0014-variance-markers-are-class-level-only.md @@ -87,6 +87,6 @@ functions, and pinned by tests for all four shapes. The boundary is documented i - [ADR-0001](0001-monomorphization-over-type-erasure.md) — specializations and their class identities (or lack thereof for functions). -- [ADR-0013](0013-variance-erased-constructor-parameters.md) — the class-level variance - surface this boundary sits alongside. +- [ADR-0013](0013-typed-constructor-parameters-on-variant-classes.md) — the class-level + variance surface this boundary sits alongside. - [Variance](../syntax/variance.md), [Caveats](../caveats.md). diff --git a/docs/adr/README.md b/docs/adr/README.md index 2fb9dc9..2bae3ff 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -30,5 +30,5 @@ should be added here as a new numbered file; copy | [0010](0010-undeclared-type-and-arity-validation.md) | Undeclared-type and arity validation | Accepted | | [0011](0011-phar-distribution.md) | PHAR distribution via Humbug Box | Accepted | | [0012](0012-engineering-quality-bar.md) | The engineering quality bar | Accepted | -| [0013](0013-variance-erased-constructor-parameters.md) | Variance-erased constructor parameters | Accepted | +| [0013](0013-typed-constructor-parameters-on-variant-classes.md) | Typed constructor parameters on variant classes | Accepted | | [0014](0014-variance-markers-are-class-level-only.md) | Variance markers are class-level only | Accepted | diff --git a/docs/syntax/variance.md b/docs/syntax/variance.md index 228cfe1..8e6b6d3 100644 --- a/docs/syntax/variance.md +++ b/docs/syntax/variance.md @@ -73,7 +73,7 @@ Position rules enforced at parse time: |---------------------------------------|---------------|---------------| | Method return type | ✅ | ❌ | | Method parameter | ❌ | ✅ | -| Constructor parameter (plain) | ✅ (erased) | ✅ (erased) | +| Constructor parameter (plain) | ✅ | ✅ | | Mutable property | ❌ | ❌ | | Readonly property | ❌ | ❌ | | Promoted constructor property | ❌ | ❌ | @@ -87,12 +87,15 @@ property types across those chains regardless of `readonly` — a covariant property would PHP-fatal at autoload when the variance edge lands. A **plain (non-promoted) constructor parameter** is the exception: it may -carry `+T` / `-T`, because xphp emits it **variance-erased** — the type -parameter's bound if it's a single non-generic type, else `mixed` — so every -specialisation's `__construct` signature is identical and stays LSP-compatible -across the edge. That's what lets a covariant immutable collection take typed -construction input (see below). A *promoted* constructor parameter is a -property, so it stays strictly invariant. +carry `+T` / `-T` at any variance, and xphp emits it with its **real** +substituted type. A constructor parameter isn't part of the externally-visible +variance surface (a constructor is never reached through an upcast reference — +the same reason Kotlin exempts constructor parameters from variance checks), and +PHP exempts `__construct` from LSP signature checks, so the specialisations' +constructors may legitimately differ across the edge. That's what lets a +covariant immutable collection take *type-checked* construction input (see +below). A *promoted* constructor parameter is a property, so it stays strictly +invariant. ### Covariant immutable collections (typed construction) @@ -114,16 +117,20 @@ $books = new ImmutableList::(new Book(), new Book()); $p = firstProduct($books); ``` -The constructor parameter is emitted as `mixed ...$items` (or the bound) on -every specialisation, so `ImmutableList` can `extends ImmutableList` -without a PHP fatal. (`final` is preserved in your source; xphp drops it only on -the internal generated specialisations so the edge can land.) - -> ⚠️ **Construction is not runtime-type-checked.** Because the emitted -> constructor parameter is erased to `mixed`/the bound, PHP performs no runtime -> element-type check at construction, and the compiler does not yet statically -> check the supplied arguments at the call site. Covariance and the typed -> *source* surface hold; a stricter construction-time check is future work. +The constructor parameter keeps its real element type on every specialisation +(`Book ...$items` on `ImmutableList`, `Product ...$items` on +`ImmutableList`), and `ImmutableList` still `extends +ImmutableList` without a PHP fatal — PHP doesn't signature-check +`__construct` across the chain. (`final` is preserved in your source; xphp drops +it only on the internal generated specialisations so the edge can land.) + +> ✅ **Construction is runtime-type-checked.** Because the constructor parameter +> keeps its real type, PHP enforces it at construction: building an +> `ImmutableList` from a non-`Book` throws a `TypeError`. You get both +> covariance *and* a real construction-time guarantee — nothing is erased. The +> one position that can't carry a real `T` is a stored **property** (PHP property +> types are invariant across the edge), so hold elements in a plain `array`/`mixed` +> backing field and expose them through a covariant `get(): T`, as above. ### Inner-template variance composition From 6a6c211270c25656afcd7f7e663e752ac6095b52 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 21 Jun 2026 13:58:45 +0000 Subject: [PATCH 047/114] test(monomorphize): move constructor-variance tests to fixtures Replace the embedded `.xphp` source strings in the constructor-variance tests with tracked fixtures under test/fixture/, per the project convention that test files use fixtures rather than inline PHP-as-string. Adds compile fixtures generic_{covariant_bounded,mixed_variance, contravariant,two_covariant,invariant}_ctor and check fixtures variance_ctor_{nullable,nested_generic,mixed_params}; the contravariant autoload test reuses the contravariant fixture. The now-unused inline helpers (compileInlineAndReadGenerated / compileExpectingVarianceViolation) are removed in favour of fixture-based compileFixtureAndReadGenerated / compileFixtureExpectingVarianceViolation. Behaviour and assertions are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../VarianceEdgeIntegrationTest.php | 96 +++++++------------ .../source/Mixed.xphp | 14 +++ .../source/Nested.xphp | 18 ++++ .../source/Nullable.xphp | 14 +++ .../source/Banana.xphp | 9 ++ .../source/Consumer.xphp | 23 +++++ .../source/Fruit.xphp | 9 ++ .../source/Use.xphp | 8 ++ .../source/Box.xphp | 22 +++++ .../source/Tag.xphp | 13 +++ .../source/Use.xphp | 7 ++ .../generic_invariant_ctor/source/Apple.xphp | 9 ++ .../generic_invariant_ctor/source/Holder.xphp | 14 +++ .../generic_invariant_ctor/source/Use.xphp | 7 ++ .../source/Apple.xphp | 9 ++ .../source/Pair.xphp | 22 +++++ .../source/Use.xphp | 7 ++ .../source/Apple.xphp | 9 ++ .../source/Two.xphp | 27 ++++++ .../source/Use.xphp | 7 ++ 20 files changed, 282 insertions(+), 62 deletions(-) create mode 100644 test/fixture/check/variance_ctor_mixed_params/source/Mixed.xphp create mode 100644 test/fixture/check/variance_ctor_nested_generic/source/Nested.xphp create mode 100644 test/fixture/check/variance_ctor_nullable/source/Nullable.xphp create mode 100644 test/fixture/compile/generic_contravariant_ctor/source/Banana.xphp create mode 100644 test/fixture/compile/generic_contravariant_ctor/source/Consumer.xphp create mode 100644 test/fixture/compile/generic_contravariant_ctor/source/Fruit.xphp create mode 100644 test/fixture/compile/generic_contravariant_ctor/source/Use.xphp create mode 100644 test/fixture/compile/generic_covariant_bounded_ctor/source/Box.xphp create mode 100644 test/fixture/compile/generic_covariant_bounded_ctor/source/Tag.xphp create mode 100644 test/fixture/compile/generic_covariant_bounded_ctor/source/Use.xphp create mode 100644 test/fixture/compile/generic_invariant_ctor/source/Apple.xphp create mode 100644 test/fixture/compile/generic_invariant_ctor/source/Holder.xphp create mode 100644 test/fixture/compile/generic_invariant_ctor/source/Use.xphp create mode 100644 test/fixture/compile/generic_mixed_variance_ctor/source/Apple.xphp create mode 100644 test/fixture/compile/generic_mixed_variance_ctor/source/Pair.xphp create mode 100644 test/fixture/compile/generic_mixed_variance_ctor/source/Use.xphp create mode 100644 test/fixture/compile/generic_two_covariant_ctor/source/Apple.xphp create mode 100644 test/fixture/compile/generic_two_covariant_ctor/source/Two.xphp create mode 100644 test/fixture/compile/generic_two_covariant_ctor/source/Use.xphp diff --git a/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php b/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php index 95369c4..20ee510 100644 --- a/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php +++ b/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php @@ -93,11 +93,7 @@ public function testBoundedCovariantConstructorKeepsConcreteType(): void { // A bounded covariant ctor param keeps its REAL substituted type (the // concrete arg, not the bound and not `mixed`) — constructors are LSP-exempt. - $generated = $this->compileInlineAndReadGenerated([ - 'Box.xphp' => "\n{\n private array \$items;\n public function __construct(T ...\$items) { \$this->items = \$items; }\n public function get(int \$i): T { return \$this->items[\$i]; }\n}\n", - 'Tag.xphp' => " "(new Tag());\n", - ]); + $generated = $this->compileFixtureAndReadGenerated('compile/generic_covariant_bounded_ctor/source'); self::assertStringContainsString('__construct(\\App\\BoundCtor\\Tag ...$items)', $generated); self::assertStringNotContainsString('__construct(mixed', $generated); self::assertStringNotContainsString('__construct(\\Stringable', $generated); @@ -108,11 +104,7 @@ public function testMixedVarianceConstructorKeepsConcreteTypes(): void // `Pair<+A, B>`: the covariant `A` ctor param keeps its concrete type, the // invariant `B` param keeps its concrete substituted type, and a plain // scalar param (`int $tag`) is left untouched (it isn't a type-param). - $generated = $this->compileInlineAndReadGenerated([ - 'Pair.xphp' => "\n{\n private array \$slots;\n public function __construct(A \$a, B \$b, int \$tag) { \$this->slots = [\$a, \$b, \$tag]; }\n public function first(): A { return \$this->slots[0]; }\n}\n", - 'Apple.xphp' => " "(new Apple(), new Apple(), 5);\n", - ]); + $generated = $this->compileFixtureAndReadGenerated('compile/generic_mixed_variance_ctor/source'); self::assertMatchesRegularExpression('/__construct\(\\\\App\\\\MixedCtor\\\\Apple \$a, \\\\App\\\\MixedCtor\\\\Apple \$b, int \$tag\)/', $generated); self::assertStringNotContainsString('mixed $a', $generated); } @@ -122,12 +114,7 @@ public function testContravariantConstructorParamKeepsConcreteType(): void // Symmetry with the covariant case: a `-T` ctor param keeps its real type // too, and the contravariant edge (Consumer extends Consumer) // stays valid because constructors are LSP-exempt. - $generated = $this->compileInlineAndReadGenerated([ - 'Consumer.xphp' => "\n{\n private array \$items;\n public function __construct(T ...\$items) { \$this->items = \$items; }\n public function accept(T \$x): void { \$this->items[] = \$x; }\n}\n", - 'Fruit.xphp' => " " "();\n\$b = new Consumer::();\n", - ]); + $generated = $this->compileFixtureAndReadGenerated('compile/generic_contravariant_ctor/source'); self::assertSame(1, preg_match_all('/function __construct\(\\\\App\\\\ContraCtor\\\\Banana \.\.\.\$items\)/', $generated)); self::assertSame(1, preg_match_all('/function __construct\(\\\\App\\\\ContraCtor\\\\Fruit \.\.\.\$items\)/', $generated)); self::assertStringNotContainsString('mixed ...$items', $generated); @@ -143,13 +130,13 @@ public function testNonBareVariantConstructorParamShapesAreRejected(): void // - `Box` — T through another generic's invariant slot // - `(T $a, ?T $b)` — the allowed leading `T` must not stop the walk from // reaching the bad trailing `?T` - $cases = [ - "class P<+T>\n{\n public function __construct(?T \$x) {}\n}\n", - "class Box {}\nclass P<+T>\n{\n public function __construct(Box \$b) {}\n}\n", - "class P<+T>\n{\n public function __construct(T \$a, ?T \$b) {}\n}\n", + $fixtures = [ + 'check/variance_ctor_nullable/source', + 'check/variance_ctor_nested_generic/source', + 'check/variance_ctor_mixed_params/source', ]; - foreach ($cases as $i => $body) { - $this->compileExpectingVarianceViolation("compileFixtureExpectingVarianceViolation($fixture); } } @@ -157,11 +144,7 @@ public function testTwoCovariantParamsBothKeepConcreteTypes(): void { // Two covariant params: BOTH `T`-typed ctor params keep their real // substituted types (pins that nothing is erased for any variant ctor param). - $generated = $this->compileInlineAndReadGenerated([ - 'Two.xphp' => "\n{\n private array \$slots;\n public function __construct(A \$a, B \$b) { \$this->slots = [\$a, \$b]; }\n public function getA(): A { return \$this->slots[0]; }\n public function getB(): B { return \$this->slots[1]; }\n}\n", - 'Apple.xphp' => " "(new Apple(), new Apple());\n", - ]); + $generated = $this->compileFixtureAndReadGenerated('compile/generic_two_covariant_ctor/source'); self::assertSame(1, preg_match_all('/__construct\(\\\\App\\\\TwoCtor\\\\Apple \$a, \\\\App\\\\TwoCtor\\\\Apple \$b\)/', $generated)); self::assertStringNotContainsString('mixed $a', $generated); } @@ -170,11 +153,7 @@ public function testInvariantClassConstructorIsNotErasedAndKeepsFinal(): void { // An invariant class is not variance-erased (ctor param keeps its concrete // type) and its `final` modifier is preserved (no edges → no LSP hazard). - $generated = $this->compileInlineAndReadGenerated([ - 'Holder.xphp' => "\n{\n public function __construct(public T \$item) {}\n}\n", - 'Apple.xphp' => " "(new Apple());\n", - ]); + $generated = $this->compileFixtureAndReadGenerated('compile/generic_invariant_ctor/source'); self::assertStringContainsString('final class', $generated); self::assertStringContainsString('App\\InvCtor\\Apple $item', $generated); self::assertStringNotContainsString('mixed $item', $generated); @@ -294,23 +273,18 @@ public function testContravariantConstructorChainAutoloadsAndConstructsWithoutPh // PHP exempts `__construct` from LSP, so the chain must both autoload AND // construct instances of each specialization without a fatal — empirically // confirming the same exemption holds in the contravariant direction. - $src = $this->workDir . '/src-contra-auto'; - mkdir($src, 0o755, true); - file_put_contents($src . '/Consumer.xphp', "\n{\n private array \$items;\n public function __construct(T ...\$items) { \$this->items = \$items; }\n public function accept(T \$x): void { \$this->items[] = \$x; }\n}\n"); - file_put_contents($src . '/Fruit.xphp', "(new Banana());\n\$b = new Consumer::(new Fruit());\n"); - + $src = realpath(__DIR__ . '/../../fixture/compile/generic_contravariant_ctor/source') + ?: throw new RuntimeException('Fixture missing'); $compiler = $this->buildCompiler(); $sources = (new NativeFileFinder())->find($src) ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); $compiler->compile($sources, $src, $this->targetDir, $this->cacheDir); - $bananaFqn = Registry::generatedFqn('App\\ContraAuto\\Consumer', [new TypeRef('App\\ContraAuto\\Banana')]); - $fruitFqn = Registry::generatedFqn('App\\ContraAuto\\Consumer', [new TypeRef('App\\ContraAuto\\Fruit')]); + $bananaFqn = Registry::generatedFqn('App\\ContraCtor\\Consumer', [new TypeRef('App\\ContraCtor\\Banana')]); + $fruitFqn = Registry::generatedFqn('App\\ContraCtor\\Consumer', [new TypeRef('App\\ContraCtor\\Fruit')]); $prefixes = [ 'XPHP\\Generated\\' => $this->cacheDir . '/Generated', - 'App\\ContraAuto\\' => $this->targetDir, + 'App\\ContraCtor\\' => $this->targetDir, ]; $loader = $this->workDir . '/contra-load.php'; @@ -323,8 +297,8 @@ public function testContravariantConstructorChainAutoloadsAndConstructsWithoutPh . " }\n" . " }\n" . "});\n" - . "new (" . var_export($bananaFqn, true) . ")(new \\App\\ContraAuto\\Banana());\n" - . "new (" . var_export($fruitFqn, true) . ")(new \\App\\ContraAuto\\Fruit());\n" + . "new (" . var_export($bananaFqn, true) . ")(new \\App\\ContraCtor\\Banana());\n" + . "new (" . var_export($fruitFqn, true) . ")(new \\App\\ContraCtor\\Fruit());\n" . "echo \"OK\\n\";\n"; file_put_contents($loader, $script); @@ -701,20 +675,15 @@ private function writeAutoloadCheck( } /** - * Compile inline `.xphp` sources and return the concatenated text of every - * generated specialization, for asserting on emitted constructor signatures. + * Compile a tracked fixture's `source/` dir and return the concatenated text of + * every generated specialization, for asserting on emitted constructor signatures. * - * @param array $files filename → xphp source + * @param string $relFixtureDir path under `test/fixture/`, e.g. `compile/foo/source` */ - private function compileInlineAndReadGenerated(array $files): string + private function compileFixtureAndReadGenerated(string $relFixtureDir): string { - $src = $this->workDir . '/src'; - if (!is_dir($src)) { - mkdir($src, 0o755, true); - } - foreach ($files as $name => $code) { - file_put_contents($src . '/' . $name, $code); - } + $src = realpath(__DIR__ . '/../../fixture/' . $relFixtureDir) + ?: throw new RuntimeException("missing fixture: {$relFixtureDir}"); $compiler = $this->buildCompiler(); $sources = (new NativeFileFinder())->find($src) ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); @@ -735,19 +704,22 @@ private function compileInlineAndReadGenerated(array $files): string } /** - * Compile a single inline source in a throwaway dir and assert it raises a - * variance violation (compile-mode, fail-fast). + * Compile a tracked fixture's `source/` dir and assert it raises a variance + * violation (compile-mode, fail-fast). + * + * @param string $relFixtureDir path under `test/fixture/`, e.g. `check/foo/source` */ - private function compileExpectingVarianceViolation(string $source): void + private function compileFixtureExpectingVarianceViolation(string $relFixtureDir): void { + $src = realpath(__DIR__ . '/../../fixture/' . $relFixtureDir) + ?: throw new RuntimeException("missing fixture: {$relFixtureDir}"); $dir = sys_get_temp_dir() . '/xphp-iv-' . uniqid('', true); - mkdir($dir . '/src', 0o755, true); - file_put_contents($dir . '/src/S.xphp', $source); + mkdir($dir, 0o755, true); $compiler = $this->buildCompiler(); - $sources = (new NativeFileFinder())->find($dir . '/src') + $sources = (new NativeFileFinder())->find($src) ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); try { - $compiler->compile($sources, $dir . '/src', $dir . '/dist', $dir . '/cache'); + $compiler->compile($sources, $src, $dir . '/dist', $dir . '/cache'); self::fail('expected a variance violation, none thrown'); } catch (RuntimeException $e) { self::assertStringContainsString('Variance violation', $e->getMessage()); diff --git a/test/fixture/check/variance_ctor_mixed_params/source/Mixed.xphp b/test/fixture/check/variance_ctor_mixed_params/source/Mixed.xphp new file mode 100644 index 0000000..fe496d4 --- /dev/null +++ b/test/fixture/check/variance_ctor_mixed_params/source/Mixed.xphp @@ -0,0 +1,14 @@ + +{ + public function __construct(T $a, ?T $b) + { + } +} diff --git a/test/fixture/check/variance_ctor_nested_generic/source/Nested.xphp b/test/fixture/check/variance_ctor_nested_generic/source/Nested.xphp new file mode 100644 index 0000000..e8fb3bd --- /dev/null +++ b/test/fixture/check/variance_ctor_nested_generic/source/Nested.xphp @@ -0,0 +1,18 @@ + +{ +} + +// Rejected: `T` reaches the constructor through another generic's invariant slot +// (`Box`), which is not a bare type-param — the inner-variance check rejects it. +class P<+T> +{ + public function __construct(Box $b) + { + } +} diff --git a/test/fixture/check/variance_ctor_nullable/source/Nullable.xphp b/test/fixture/check/variance_ctor_nullable/source/Nullable.xphp new file mode 100644 index 0000000..ee3c929 --- /dev/null +++ b/test/fixture/check/variance_ctor_nullable/source/Nullable.xphp @@ -0,0 +1,14 @@ + +{ + public function __construct(?T $x) + { + } +} diff --git a/test/fixture/compile/generic_contravariant_ctor/source/Banana.xphp b/test/fixture/compile/generic_contravariant_ctor/source/Banana.xphp new file mode 100644 index 0000000..f2ef639 --- /dev/null +++ b/test/fixture/compile/generic_contravariant_ctor/source/Banana.xphp @@ -0,0 +1,9 @@ +` extends `Consumer` — and the chain still loads +// because PHP exempts `__construct` from LSP. +class Consumer<-T> +{ + private array $items; + + public function __construct(T ...$items) + { + $this->items = $items; + } + + public function accept(T $x): void + { + $this->items[] = $x; + } +} diff --git a/test/fixture/compile/generic_contravariant_ctor/source/Fruit.xphp b/test/fixture/compile/generic_contravariant_ctor/source/Fruit.xphp new file mode 100644 index 0000000..0a1f68e --- /dev/null +++ b/test/fixture/compile/generic_contravariant_ctor/source/Fruit.xphp @@ -0,0 +1,9 @@ +(new Banana()); +$b = new Consumer::(new Fruit()); diff --git a/test/fixture/compile/generic_covariant_bounded_ctor/source/Box.xphp b/test/fixture/compile/generic_covariant_bounded_ctor/source/Box.xphp new file mode 100644 index 0000000..58ff973 --- /dev/null +++ b/test/fixture/compile/generic_covariant_bounded_ctor/source/Box.xphp @@ -0,0 +1,22 @@ + +{ + private array $items; + + public function __construct(T ...$items) + { + $this->items = $items; + } + + public function get(int $i): T + { + return $this->items[$i]; + } +} diff --git a/test/fixture/compile/generic_covariant_bounded_ctor/source/Tag.xphp b/test/fixture/compile/generic_covariant_bounded_ctor/source/Tag.xphp new file mode 100644 index 0000000..ea29850 --- /dev/null +++ b/test/fixture/compile/generic_covariant_bounded_ctor/source/Tag.xphp @@ -0,0 +1,13 @@ +(new Tag()); diff --git a/test/fixture/compile/generic_invariant_ctor/source/Apple.xphp b/test/fixture/compile/generic_invariant_ctor/source/Apple.xphp new file mode 100644 index 0000000..1a5beda --- /dev/null +++ b/test/fixture/compile/generic_invariant_ctor/source/Apple.xphp @@ -0,0 +1,9 @@ + +{ + public function __construct(public T $item) + { + } +} diff --git a/test/fixture/compile/generic_invariant_ctor/source/Use.xphp b/test/fixture/compile/generic_invariant_ctor/source/Use.xphp new file mode 100644 index 0000000..6a61236 --- /dev/null +++ b/test/fixture/compile/generic_invariant_ctor/source/Use.xphp @@ -0,0 +1,7 @@ +(new Apple()); diff --git a/test/fixture/compile/generic_mixed_variance_ctor/source/Apple.xphp b/test/fixture/compile/generic_mixed_variance_ctor/source/Apple.xphp new file mode 100644 index 0000000..1c08298 --- /dev/null +++ b/test/fixture/compile/generic_mixed_variance_ctor/source/Apple.xphp @@ -0,0 +1,9 @@ +`: the covariant `A` and the invariant `B` constructor params both +// keep their concrete substituted types; the scalar `int $tag` is untouched. +class Pair<+A, B> +{ + private array $slots; + + public function __construct(A $a, B $b, int $tag) + { + $this->slots = [$a, $b, $tag]; + } + + public function first(): A + { + return $this->slots[0]; + } +} diff --git a/test/fixture/compile/generic_mixed_variance_ctor/source/Use.xphp b/test/fixture/compile/generic_mixed_variance_ctor/source/Use.xphp new file mode 100644 index 0000000..3d38b5a --- /dev/null +++ b/test/fixture/compile/generic_mixed_variance_ctor/source/Use.xphp @@ -0,0 +1,7 @@ +(new Apple(), new Apple(), 5); diff --git a/test/fixture/compile/generic_two_covariant_ctor/source/Apple.xphp b/test/fixture/compile/generic_two_covariant_ctor/source/Apple.xphp new file mode 100644 index 0000000..2a04eae --- /dev/null +++ b/test/fixture/compile/generic_two_covariant_ctor/source/Apple.xphp @@ -0,0 +1,9 @@ + +{ + private array $slots; + + public function __construct(A $a, B $b) + { + $this->slots = [$a, $b]; + } + + public function getA(): A + { + return $this->slots[0]; + } + + public function getB(): B + { + return $this->slots[1]; + } +} diff --git a/test/fixture/compile/generic_two_covariant_ctor/source/Use.xphp b/test/fixture/compile/generic_two_covariant_ctor/source/Use.xphp new file mode 100644 index 0000000..f23e3e4 --- /dev/null +++ b/test/fixture/compile/generic_two_covariant_ctor/source/Use.xphp @@ -0,0 +1,7 @@ +(new Apple(), new Apple()); From 87e8db6f6ff47c51c8f7ca23731992cb7b4b9b2a Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 21 Jun 2026 14:13:38 +0000 Subject: [PATCH 048/114] test,docs: rename ctor -> constructor and Contra -> ContraVariance Apply the naming convention to the variance-constructor test artifacts: constructor (not the `ctor` abbreviation) and ContraVariance (not the `Contra` abbreviation). - Fixture dirs `generic_*_ctor` -> `generic_*_constructor` and `check/variance_ctor_*` -> `check/variance_constructor_*`, with their namespaces (`App\BoundCtor` -> `App\BoundConstructor`, `App\ContraCtor` -> `App\ContraVarianceConstructor`, etc.). - Test references, assertions, and comments updated to match; lowercase `ctor` in comments/messages expanded to `constructor`. - The nested-generic check fixture is split into one class per file (Box.xphp + P.xphp) to match the repo convention. - Doc fix: ADR-0013 "non-bare ctor shapes" -> "non-bare constructor shapes". No behavioural change; full suite green, PHPStan L9 clean, 100% MSI. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...nstructor-parameters-on-variant-classes.md | 2 +- .../VarianceEdgeIntegrationTest.php | 82 +++++++++---------- .../source/P.xphp} | 2 +- .../source/Box.xphp | 9 ++ .../source/P.xphp} | 6 +- .../source/P.xphp} | 2 +- .../source/Banana.xphp | 2 +- .../source/Consumer.xphp | 2 +- .../source/Fruit.xphp | 2 +- .../source/Use.xphp | 2 +- .../source/Box.xphp | 2 +- .../source/Tag.xphp | 2 +- .../source/Use.xphp | 2 +- .../source/Banana.xphp | 2 +- .../source/Fruit.xphp | 2 +- .../source/ImmutableList.xphp | 2 +- .../source/Use.xphp | 2 +- .../verify/runtime.php | 10 +-- .../source/Apple.xphp | 2 +- .../source/Holder.xphp | 4 +- .../source/Use.xphp | 2 +- .../source/Apple.xphp | 2 +- .../source/Pair.xphp | 2 +- .../source/Use.xphp | 2 +- .../source/Apple.xphp | 2 +- .../source/Two.xphp | 4 +- .../source/Use.xphp | 2 +- 27 files changed, 81 insertions(+), 76 deletions(-) rename test/fixture/check/{variance_ctor_mixed_params/source/Mixed.xphp => variance_constructor_mixed_params/source/P.xphp} (89%) create mode 100644 test/fixture/check/variance_constructor_nested_generic/source/Box.xphp rename test/fixture/check/{variance_ctor_nested_generic/source/Nested.xphp => variance_constructor_nested_generic/source/P.xphp} (86%) rename test/fixture/check/{variance_ctor_nullable/source/Nullable.xphp => variance_constructor_nullable/source/P.xphp} (87%) rename test/fixture/compile/{generic_contravariant_ctor => generic_contravariant_constructor}/source/Banana.xphp (61%) rename test/fixture/compile/{generic_contravariant_ctor => generic_contravariant_constructor}/source/Consumer.xphp (91%) rename test/fixture/compile/{generic_contravariant_ctor => generic_contravariant_constructor}/source/Fruit.xphp (54%) rename test/fixture/compile/{generic_contravariant_ctor => generic_contravariant_constructor}/source/Use.xphp (74%) rename test/fixture/compile/{generic_covariant_bounded_ctor => generic_covariant_bounded_constructor}/source/Box.xphp (92%) rename test/fixture/compile/{generic_covariant_bounded_ctor => generic_covariant_bounded_constructor}/source/Tag.xphp (82%) rename test/fixture/compile/{generic_covariant_bounded_ctor => generic_covariant_bounded_constructor}/source/Use.xphp (67%) rename test/fixture/compile/{generic_covariant_immutable_ctor => generic_covariant_immutable_constructor}/source/Banana.xphp (80%) rename test/fixture/compile/{generic_covariant_immutable_ctor => generic_covariant_immutable_constructor}/source/Fruit.xphp (77%) rename test/fixture/compile/{generic_covariant_immutable_ctor => generic_covariant_immutable_constructor}/source/ImmutableList.xphp (95%) rename test/fixture/compile/{generic_covariant_immutable_ctor => generic_covariant_immutable_constructor}/source/Use.xphp (91%) rename test/fixture/compile/{generic_covariant_immutable_ctor => generic_covariant_immutable_constructor}/verify/runtime.php (84%) rename test/fixture/compile/{generic_invariant_ctor => generic_invariant_constructor}/source/Apple.xphp (62%) rename test/fixture/compile/{generic_invariant_ctor => generic_invariant_constructor}/source/Holder.xphp (63%) rename test/fixture/compile/{generic_invariant_ctor => generic_invariant_constructor}/source/Use.xphp (70%) rename test/fixture/compile/{generic_two_covariant_ctor => generic_mixed_variance_constructor}/source/Apple.xphp (60%) rename test/fixture/compile/{generic_mixed_variance_ctor => generic_mixed_variance_constructor}/source/Pair.xphp (92%) rename test/fixture/compile/{generic_mixed_variance_ctor => generic_mixed_variance_constructor}/source/Use.xphp (74%) rename test/fixture/compile/{generic_mixed_variance_ctor => generic_two_covariant_constructor}/source/Apple.xphp (62%) rename test/fixture/compile/{generic_two_covariant_ctor => generic_two_covariant_constructor}/source/Two.xphp (78%) rename test/fixture/compile/{generic_two_covariant_ctor => generic_two_covariant_constructor}/source/Use.xphp (75%) diff --git a/docs/adr/0013-typed-constructor-parameters-on-variant-classes.md b/docs/adr/0013-typed-constructor-parameters-on-variant-classes.md index 7ffb44e..c09dd7d 100644 --- a/docs/adr/0013-typed-constructor-parameters-on-variant-classes.md +++ b/docs/adr/0013-typed-constructor-parameters-on-variant-classes.md @@ -91,7 +91,7 @@ autoloads and constructs equally cleanly. - Good: keeps the real type and a runtime check; no autoload fatal; localized to the specializer (just normal substitution). -- Bad: properties still can't carry a real `T`; non-bare ctor shapes not yet supported. +- Bad: properties still can't carry a real `T`; non-bare constructor shapes not yet supported. ### Variance-erased constructor parameter diff --git a/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php b/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php index 20ee510..9b40e92 100644 --- a/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php +++ b/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php @@ -40,17 +40,17 @@ protected function tearDown(): void public function testCovariantImmutableCollectionTakesTypedConstructorInput(): void { // A covariant immutable collection `ImmutableList<+T>` with a `T`-typed - // constructor. The ctor param keeps its REAL element type on each + // constructor. The constructor param keeps its REAL element type on each // specialization (`Fruit ...` / `Banana ...`) — PHP exempts `__construct` // from LSP, so `ImmutableList` extends `ImmutableList` with // NO autoload fatal, a Banana list is usable where a Fruit list is // expected, and construction is runtime-type-checked. $fixture = CompiledFixture::compile( - __DIR__ . '/../../fixture/compile/generic_covariant_immutable_ctor/source', - 'variance-covariant-ctor', + __DIR__ . '/../../fixture/compile/generic_covariant_immutable_constructor/source', + 'variance-covariant-constructor', ); try { - $specDir = $fixture->cacheDir . '/Generated/App/CovariantCtor/ImmutableList'; + $specDir = $fixture->cacheDir . '/Generated/App/CovariantConstructor/ImmutableList'; $files = glob($specDir . '/T_*.php') ?: []; self::assertCount(2, $files, 'two ImmutableList specializations (Fruit, Banana)'); @@ -60,20 +60,20 @@ public function testCovariantImmutableCollectionTakesTypedConstructorInput(): vo $content = file_get_contents($file); self::assertIsString($content); $combined .= $content; - if (str_contains($content, 'extends \\XPHP\\Generated\\App\\CovariantCtor\\ImmutableList\\T_')) { + if (str_contains($content, 'extends \\XPHP\\Generated\\App\\CovariantConstructor\\ImmutableList\\T_')) { $extendsEdges++; } } // Each specialization keeps its REAL element type in the constructor. self::assertSame( 1, - preg_match_all('/function __construct\(\\\\App\\\\CovariantCtor\\\\Fruit \.\.\.\$items\)/', $combined), - 'Fruit specialization ctor keeps `Fruit ...$items`', + preg_match_all('/function __construct\(\\\\App\\\\CovariantConstructor\\\\Fruit \.\.\.\$items\)/', $combined), + 'Fruit specialization constructor keeps `Fruit ...$items`', ); self::assertSame( 1, - preg_match_all('/function __construct\(\\\\App\\\\CovariantCtor\\\\Banana \.\.\.\$items\)/', $combined), - 'Banana specialization ctor keeps `Banana ...$items`', + preg_match_all('/function __construct\(\\\\App\\\\CovariantConstructor\\\\Banana \.\.\.\$items\)/', $combined), + 'Banana specialization constructor keeps `Banana ...$items`', ); self::assertStringNotContainsString('mixed ...$items', $combined, 'nothing is erased to mixed'); // Exactly one specialization extends the other — the covariant edge. @@ -82,8 +82,8 @@ public function testCovariantImmutableCollectionTakesTypedConstructorInput(): vo // parent isn't a final class (which would PHP-fatal at autoload). self::assertStringNotContainsString('final class', $combined); - $fixture->registerAutoload('App\\CovariantCtor'); - require __DIR__ . '/../../fixture/compile/generic_covariant_immutable_ctor/verify/runtime.php'; + $fixture->registerAutoload('App\\CovariantConstructor'); + require __DIR__ . '/../../fixture/compile/generic_covariant_immutable_constructor/verify/runtime.php'; } finally { $fixture->cleanup(); } @@ -91,49 +91,49 @@ public function testCovariantImmutableCollectionTakesTypedConstructorInput(): vo public function testBoundedCovariantConstructorKeepsConcreteType(): void { - // A bounded covariant ctor param keeps its REAL substituted type (the + // A bounded covariant constructor param keeps its REAL substituted type (the // concrete arg, not the bound and not `mixed`) — constructors are LSP-exempt. - $generated = $this->compileFixtureAndReadGenerated('compile/generic_covariant_bounded_ctor/source'); - self::assertStringContainsString('__construct(\\App\\BoundCtor\\Tag ...$items)', $generated); + $generated = $this->compileFixtureAndReadGenerated('compile/generic_covariant_bounded_constructor/source'); + self::assertStringContainsString('__construct(\\App\\BoundConstructor\\Tag ...$items)', $generated); self::assertStringNotContainsString('__construct(mixed', $generated); self::assertStringNotContainsString('__construct(\\Stringable', $generated); } public function testMixedVarianceConstructorKeepsConcreteTypes(): void { - // `Pair<+A, B>`: the covariant `A` ctor param keeps its concrete type, the + // `Pair<+A, B>`: the covariant `A` constructor param keeps its concrete type, the // invariant `B` param keeps its concrete substituted type, and a plain // scalar param (`int $tag`) is left untouched (it isn't a type-param). - $generated = $this->compileFixtureAndReadGenerated('compile/generic_mixed_variance_ctor/source'); - self::assertMatchesRegularExpression('/__construct\(\\\\App\\\\MixedCtor\\\\Apple \$a, \\\\App\\\\MixedCtor\\\\Apple \$b, int \$tag\)/', $generated); + $generated = $this->compileFixtureAndReadGenerated('compile/generic_mixed_variance_constructor/source'); + self::assertMatchesRegularExpression('/__construct\(\\\\App\\\\MixedConstructor\\\\Apple \$a, \\\\App\\\\MixedConstructor\\\\Apple \$b, int \$tag\)/', $generated); self::assertStringNotContainsString('mixed $a', $generated); } public function testContravariantConstructorParamKeepsConcreteType(): void { - // Symmetry with the covariant case: a `-T` ctor param keeps its real type + // Symmetry with the covariant case: a `-T` constructor param keeps its real type // too, and the contravariant edge (Consumer extends Consumer) // stays valid because constructors are LSP-exempt. - $generated = $this->compileFixtureAndReadGenerated('compile/generic_contravariant_ctor/source'); - self::assertSame(1, preg_match_all('/function __construct\(\\\\App\\\\ContraCtor\\\\Banana \.\.\.\$items\)/', $generated)); - self::assertSame(1, preg_match_all('/function __construct\(\\\\App\\\\ContraCtor\\\\Fruit \.\.\.\$items\)/', $generated)); + $generated = $this->compileFixtureAndReadGenerated('compile/generic_contravariant_constructor/source'); + self::assertSame(1, preg_match_all('/function __construct\(\\\\App\\\\ContraVarianceConstructor\\\\Banana \.\.\.\$items\)/', $generated)); + self::assertSame(1, preg_match_all('/function __construct\(\\\\App\\\\ContraVarianceConstructor\\\\Fruit \.\.\.\$items\)/', $generated)); self::assertStringNotContainsString('mixed ...$items', $generated); - self::assertStringContainsString('extends \\XPHP\\Generated\\App\\ContraCtor\\Consumer\\T_', $generated); + self::assertStringContainsString('extends \\XPHP\\Generated\\App\\ContraVarianceConstructor\\Consumer\\T_', $generated); } public function testNonBareVariantConstructorParamShapesAreRejected(): void { // Only a *bare* variance-marked type-param is currently supported in a // constructor parameter. Richer shapes are still rejected by the - // inner-variance check (not yet supported in ctor position): + // inner-variance check (not yet supported in constructor position): // - `?T` — nullable, not a bare Name // - `Box` — T through another generic's invariant slot // - `(T $a, ?T $b)` — the allowed leading `T` must not stop the walk from // reaching the bad trailing `?T` $fixtures = [ - 'check/variance_ctor_nullable/source', - 'check/variance_ctor_nested_generic/source', - 'check/variance_ctor_mixed_params/source', + 'check/variance_constructor_nullable/source', + 'check/variance_constructor_nested_generic/source', + 'check/variance_constructor_mixed_params/source', ]; foreach ($fixtures as $fixture) { $this->compileFixtureExpectingVarianceViolation($fixture); @@ -142,20 +142,20 @@ public function testNonBareVariantConstructorParamShapesAreRejected(): void public function testTwoCovariantParamsBothKeepConcreteTypes(): void { - // Two covariant params: BOTH `T`-typed ctor params keep their real - // substituted types (pins that nothing is erased for any variant ctor param). - $generated = $this->compileFixtureAndReadGenerated('compile/generic_two_covariant_ctor/source'); - self::assertSame(1, preg_match_all('/__construct\(\\\\App\\\\TwoCtor\\\\Apple \$a, \\\\App\\\\TwoCtor\\\\Apple \$b\)/', $generated)); + // Two covariant params: BOTH `T`-typed constructor params keep their real + // substituted types (pins that nothing is erased for any variant constructor param). + $generated = $this->compileFixtureAndReadGenerated('compile/generic_two_covariant_constructor/source'); + self::assertSame(1, preg_match_all('/__construct\(\\\\App\\\\TwoConstructor\\\\Apple \$a, \\\\App\\\\TwoConstructor\\\\Apple \$b\)/', $generated)); self::assertStringNotContainsString('mixed $a', $generated); } public function testInvariantClassConstructorIsNotErasedAndKeepsFinal(): void { - // An invariant class is not variance-erased (ctor param keeps its concrete + // An invariant class is not variance-erased (constructor param keeps its concrete // type) and its `final` modifier is preserved (no edges → no LSP hazard). - $generated = $this->compileFixtureAndReadGenerated('compile/generic_invariant_ctor/source'); + $generated = $this->compileFixtureAndReadGenerated('compile/generic_invariant_constructor/source'); self::assertStringContainsString('final class', $generated); - self::assertStringContainsString('App\\InvCtor\\Apple $item', $generated); + self::assertStringContainsString('App\\InvConstructor\\Apple $item', $generated); self::assertStringNotContainsString('mixed $item', $generated); } @@ -269,22 +269,22 @@ public function testContravariantConstructorChainAutoloadsAndConstructsWithoutPh { // The CONTRAVARIANT counterpart of the autoload proof, with a real-typed // constructor. The edge flips: `Consumer` extends `Consumer`, - // so the child ctor (`Fruit ...$items`) WIDENS the parent's (`Banana ...`). + // so the child constructor (`Fruit ...$items`) WIDENS the parent's (`Banana ...`). // PHP exempts `__construct` from LSP, so the chain must both autoload AND // construct instances of each specialization without a fatal — empirically // confirming the same exemption holds in the contravariant direction. - $src = realpath(__DIR__ . '/../../fixture/compile/generic_contravariant_ctor/source') + $src = realpath(__DIR__ . '/../../fixture/compile/generic_contravariant_constructor/source') ?: throw new RuntimeException('Fixture missing'); $compiler = $this->buildCompiler(); $sources = (new NativeFileFinder())->find($src) ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); $compiler->compile($sources, $src, $this->targetDir, $this->cacheDir); - $bananaFqn = Registry::generatedFqn('App\\ContraCtor\\Consumer', [new TypeRef('App\\ContraCtor\\Banana')]); - $fruitFqn = Registry::generatedFqn('App\\ContraCtor\\Consumer', [new TypeRef('App\\ContraCtor\\Fruit')]); + $bananaFqn = Registry::generatedFqn('App\\ContraVarianceConstructor\\Consumer', [new TypeRef('App\\ContraVarianceConstructor\\Banana')]); + $fruitFqn = Registry::generatedFqn('App\\ContraVarianceConstructor\\Consumer', [new TypeRef('App\\ContraVarianceConstructor\\Fruit')]); $prefixes = [ 'XPHP\\Generated\\' => $this->cacheDir . '/Generated', - 'App\\ContraCtor\\' => $this->targetDir, + 'App\\ContraVarianceConstructor\\' => $this->targetDir, ]; $loader = $this->workDir . '/contra-load.php'; @@ -297,15 +297,15 @@ public function testContravariantConstructorChainAutoloadsAndConstructsWithoutPh . " }\n" . " }\n" . "});\n" - . "new (" . var_export($bananaFqn, true) . ")(new \\App\\ContraCtor\\Banana());\n" - . "new (" . var_export($fruitFqn, true) . ")(new \\App\\ContraCtor\\Fruit());\n" + . "new (" . var_export($bananaFqn, true) . ")(new \\App\\ContraVarianceConstructor\\Banana());\n" + . "new (" . var_export($fruitFqn, true) . ")(new \\App\\ContraVarianceConstructor\\Fruit());\n" . "echo \"OK\\n\";\n"; file_put_contents($loader, $script); $output = []; $exitCode = 0; exec('php ' . escapeshellarg($loader) . ' 2>&1', $output, $exitCode); - self::assertSame(0, $exitCode, "Contravariant ctor chain fataled:\n" . implode("\n", $output)); + self::assertSame(0, $exitCode, "Contravariant constructor chain fataled:\n" . implode("\n", $output)); self::assertContains('OK', $output); } diff --git a/test/fixture/check/variance_ctor_mixed_params/source/Mixed.xphp b/test/fixture/check/variance_constructor_mixed_params/source/P.xphp similarity index 89% rename from test/fixture/check/variance_ctor_mixed_params/source/Mixed.xphp rename to test/fixture/check/variance_constructor_mixed_params/source/P.xphp index fe496d4..4813f25 100644 --- a/test/fixture/check/variance_ctor_mixed_params/source/Mixed.xphp +++ b/test/fixture/check/variance_constructor_mixed_params/source/P.xphp @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\CtorMixed; +namespace App\ConstructorMixed; // Rejected: the allowed bare leading `T` must not stop the walk from reaching the // bad trailing `?T` (a non-bare shape) — the inner-variance check still rejects it. diff --git a/test/fixture/check/variance_constructor_nested_generic/source/Box.xphp b/test/fixture/check/variance_constructor_nested_generic/source/Box.xphp new file mode 100644 index 0000000..80a450e --- /dev/null +++ b/test/fixture/check/variance_constructor_nested_generic/source/Box.xphp @@ -0,0 +1,9 @@ + +{ +} diff --git a/test/fixture/check/variance_ctor_nested_generic/source/Nested.xphp b/test/fixture/check/variance_constructor_nested_generic/source/P.xphp similarity index 86% rename from test/fixture/check/variance_ctor_nested_generic/source/Nested.xphp rename to test/fixture/check/variance_constructor_nested_generic/source/P.xphp index e8fb3bd..805408b 100644 --- a/test/fixture/check/variance_ctor_nested_generic/source/Nested.xphp +++ b/test/fixture/check/variance_constructor_nested_generic/source/P.xphp @@ -2,11 +2,7 @@ declare(strict_types=1); -namespace App\CtorNested; - -class Box -{ -} +namespace App\ConstructorNested; // Rejected: `T` reaches the constructor through another generic's invariant slot // (`Box`), which is not a bare type-param — the inner-variance check rejects it. diff --git a/test/fixture/check/variance_ctor_nullable/source/Nullable.xphp b/test/fixture/check/variance_constructor_nullable/source/P.xphp similarity index 87% rename from test/fixture/check/variance_ctor_nullable/source/Nullable.xphp rename to test/fixture/check/variance_constructor_nullable/source/P.xphp index ee3c929..ac6f505 100644 --- a/test/fixture/check/variance_ctor_nullable/source/Nullable.xphp +++ b/test/fixture/check/variance_constructor_nullable/source/P.xphp @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\CtorNullable; +namespace App\ConstructorNullable; // Rejected: `?T` is not a bare type-param — a non-bare shape isn't supported in // a constructor parameter, so the inner-variance check rejects it. diff --git a/test/fixture/compile/generic_contravariant_ctor/source/Banana.xphp b/test/fixture/compile/generic_contravariant_constructor/source/Banana.xphp similarity index 61% rename from test/fixture/compile/generic_contravariant_ctor/source/Banana.xphp rename to test/fixture/compile/generic_contravariant_constructor/source/Banana.xphp index f2ef639..ff25434 100644 --- a/test/fixture/compile/generic_contravariant_ctor/source/Banana.xphp +++ b/test/fixture/compile/generic_contravariant_constructor/source/Banana.xphp @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\ContraCtor; +namespace App\ContraVarianceConstructor; class Banana extends Fruit { diff --git a/test/fixture/compile/generic_contravariant_ctor/source/Consumer.xphp b/test/fixture/compile/generic_contravariant_constructor/source/Consumer.xphp similarity index 91% rename from test/fixture/compile/generic_contravariant_ctor/source/Consumer.xphp rename to test/fixture/compile/generic_contravariant_constructor/source/Consumer.xphp index 5db8154..0fc5ea1 100644 --- a/test/fixture/compile/generic_contravariant_ctor/source/Consumer.xphp +++ b/test/fixture/compile/generic_contravariant_constructor/source/Consumer.xphp @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\ContraCtor; +namespace App\ContraVarianceConstructor; // Contravariant constructor param: keeps its real type too. The edge flips — // `Consumer` extends `Consumer` — and the chain still loads diff --git a/test/fixture/compile/generic_contravariant_ctor/source/Fruit.xphp b/test/fixture/compile/generic_contravariant_constructor/source/Fruit.xphp similarity index 54% rename from test/fixture/compile/generic_contravariant_ctor/source/Fruit.xphp rename to test/fixture/compile/generic_contravariant_constructor/source/Fruit.xphp index 0a1f68e..f5d9401 100644 --- a/test/fixture/compile/generic_contravariant_ctor/source/Fruit.xphp +++ b/test/fixture/compile/generic_contravariant_constructor/source/Fruit.xphp @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\ContraCtor; +namespace App\ContraVarianceConstructor; class Fruit { diff --git a/test/fixture/compile/generic_contravariant_ctor/source/Use.xphp b/test/fixture/compile/generic_contravariant_constructor/source/Use.xphp similarity index 74% rename from test/fixture/compile/generic_contravariant_ctor/source/Use.xphp rename to test/fixture/compile/generic_contravariant_constructor/source/Use.xphp index 131eb24..3991b45 100644 --- a/test/fixture/compile/generic_contravariant_ctor/source/Use.xphp +++ b/test/fixture/compile/generic_contravariant_constructor/source/Use.xphp @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\ContraCtor; +namespace App\ContraVarianceConstructor; $a = new Consumer::(new Banana()); $b = new Consumer::(new Fruit()); diff --git a/test/fixture/compile/generic_covariant_bounded_ctor/source/Box.xphp b/test/fixture/compile/generic_covariant_bounded_constructor/source/Box.xphp similarity index 92% rename from test/fixture/compile/generic_covariant_bounded_ctor/source/Box.xphp rename to test/fixture/compile/generic_covariant_bounded_constructor/source/Box.xphp index 58ff973..ecb8ed0 100644 --- a/test/fixture/compile/generic_covariant_bounded_ctor/source/Box.xphp +++ b/test/fixture/compile/generic_covariant_bounded_constructor/source/Box.xphp @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\BoundCtor; +namespace App\BoundConstructor; // Bounded covariant constructor param: keeps its REAL substituted type (the // concrete arg), not the bound and not `mixed`. diff --git a/test/fixture/compile/generic_covariant_bounded_ctor/source/Tag.xphp b/test/fixture/compile/generic_covariant_bounded_constructor/source/Tag.xphp similarity index 82% rename from test/fixture/compile/generic_covariant_bounded_ctor/source/Tag.xphp rename to test/fixture/compile/generic_covariant_bounded_constructor/source/Tag.xphp index ea29850..d2b8240 100644 --- a/test/fixture/compile/generic_covariant_bounded_ctor/source/Tag.xphp +++ b/test/fixture/compile/generic_covariant_bounded_constructor/source/Tag.xphp @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\BoundCtor; +namespace App\BoundConstructor; final class Tag implements \Stringable { diff --git a/test/fixture/compile/generic_covariant_bounded_ctor/source/Use.xphp b/test/fixture/compile/generic_covariant_bounded_constructor/source/Use.xphp similarity index 67% rename from test/fixture/compile/generic_covariant_bounded_ctor/source/Use.xphp rename to test/fixture/compile/generic_covariant_bounded_constructor/source/Use.xphp index 38e5e9a..406bb39 100644 --- a/test/fixture/compile/generic_covariant_bounded_ctor/source/Use.xphp +++ b/test/fixture/compile/generic_covariant_bounded_constructor/source/Use.xphp @@ -2,6 +2,6 @@ declare(strict_types=1); -namespace App\BoundCtor; +namespace App\BoundConstructor; $b = new Box::(new Tag()); diff --git a/test/fixture/compile/generic_covariant_immutable_ctor/source/Banana.xphp b/test/fixture/compile/generic_covariant_immutable_constructor/source/Banana.xphp similarity index 80% rename from test/fixture/compile/generic_covariant_immutable_ctor/source/Banana.xphp rename to test/fixture/compile/generic_covariant_immutable_constructor/source/Banana.xphp index d4c5bae..e2f0582 100644 --- a/test/fixture/compile/generic_covariant_immutable_ctor/source/Banana.xphp +++ b/test/fixture/compile/generic_covariant_immutable_constructor/source/Banana.xphp @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\CovariantCtor; +namespace App\CovariantConstructor; class Banana extends Fruit { diff --git a/test/fixture/compile/generic_covariant_immutable_ctor/source/Fruit.xphp b/test/fixture/compile/generic_covariant_immutable_constructor/source/Fruit.xphp similarity index 77% rename from test/fixture/compile/generic_covariant_immutable_ctor/source/Fruit.xphp rename to test/fixture/compile/generic_covariant_immutable_constructor/source/Fruit.xphp index a1f2697..992b0fb 100644 --- a/test/fixture/compile/generic_covariant_immutable_ctor/source/Fruit.xphp +++ b/test/fixture/compile/generic_covariant_immutable_constructor/source/Fruit.xphp @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\CovariantCtor; +namespace App\CovariantConstructor; class Fruit { diff --git a/test/fixture/compile/generic_covariant_immutable_ctor/source/ImmutableList.xphp b/test/fixture/compile/generic_covariant_immutable_constructor/source/ImmutableList.xphp similarity index 95% rename from test/fixture/compile/generic_covariant_immutable_ctor/source/ImmutableList.xphp rename to test/fixture/compile/generic_covariant_immutable_constructor/source/ImmutableList.xphp index 92549c9..73646cf 100644 --- a/test/fixture/compile/generic_covariant_immutable_ctor/source/ImmutableList.xphp +++ b/test/fixture/compile/generic_covariant_immutable_constructor/source/ImmutableList.xphp @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\CovariantCtor; +namespace App\CovariantConstructor; // Covariant immutable collection: `+T` with a `T`-typed constructor. The // constructor parameter keeps its REAL type on every specialization diff --git a/test/fixture/compile/generic_covariant_immutable_ctor/source/Use.xphp b/test/fixture/compile/generic_covariant_immutable_constructor/source/Use.xphp similarity index 91% rename from test/fixture/compile/generic_covariant_immutable_ctor/source/Use.xphp rename to test/fixture/compile/generic_covariant_immutable_constructor/source/Use.xphp index 1ce829e..f21d7b6 100644 --- a/test/fixture/compile/generic_covariant_immutable_ctor/source/Use.xphp +++ b/test/fixture/compile/generic_covariant_immutable_constructor/source/Use.xphp @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\CovariantCtor; +namespace App\CovariantConstructor; // Covariance: an ImmutableList is accepted where ImmutableList // is expected, because Banana extends Fruit and T is covariant. diff --git a/test/fixture/compile/generic_covariant_immutable_ctor/verify/runtime.php b/test/fixture/compile/generic_covariant_immutable_constructor/verify/runtime.php similarity index 84% rename from test/fixture/compile/generic_covariant_immutable_ctor/verify/runtime.php rename to test/fixture/compile/generic_covariant_immutable_constructor/verify/runtime.php index a98542e..9388b0c 100644 --- a/test/fixture/compile/generic_covariant_immutable_ctor/verify/runtime.php +++ b/test/fixture/compile/generic_covariant_immutable_constructor/verify/runtime.php @@ -3,7 +3,7 @@ declare(strict_types=1); /** - * Runtime verify for `generic_covariant_immutable_ctor`: + * Runtime verify for `generic_covariant_immutable_constructor`: * a covariant immutable collection `ImmutableList<+T>` with a `T`-typed * constructor. `ImmutableList` is usable where `ImmutableList` * is expected (covariant `extends` edge), the constructor keeps its REAL element @@ -13,8 +13,8 @@ * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. */ -use App\CovariantCtor\Banana; -use App\CovariantCtor\Fruit; +use App\CovariantConstructor\Banana; +use App\CovariantConstructor\Fruit; use PHPUnit\Framework\Assert; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeRef; @@ -27,8 +27,8 @@ // The constructor element type is REAL (not erased to `mixed`) and runtime-checked: // an `ImmutableList` rejects a plain `Fruit` at construction. $bananaListFqn = Registry::generatedFqn( - 'App\\CovariantCtor\\ImmutableList', - [new TypeRef('App\\CovariantCtor\\Banana')], + 'App\\CovariantConstructor\\ImmutableList', + [new TypeRef('App\\CovariantConstructor\\Banana')], ); $threw = false; try { diff --git a/test/fixture/compile/generic_invariant_ctor/source/Apple.xphp b/test/fixture/compile/generic_invariant_constructor/source/Apple.xphp similarity index 62% rename from test/fixture/compile/generic_invariant_ctor/source/Apple.xphp rename to test/fixture/compile/generic_invariant_constructor/source/Apple.xphp index 1a5beda..98e0ab3 100644 --- a/test/fixture/compile/generic_invariant_ctor/source/Apple.xphp +++ b/test/fixture/compile/generic_invariant_constructor/source/Apple.xphp @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\InvCtor; +namespace App\InvConstructor; class Apple { diff --git a/test/fixture/compile/generic_invariant_ctor/source/Holder.xphp b/test/fixture/compile/generic_invariant_constructor/source/Holder.xphp similarity index 63% rename from test/fixture/compile/generic_invariant_ctor/source/Holder.xphp rename to test/fixture/compile/generic_invariant_constructor/source/Holder.xphp index 1f35892..d06f620 100644 --- a/test/fixture/compile/generic_invariant_ctor/source/Holder.xphp +++ b/test/fixture/compile/generic_invariant_constructor/source/Holder.xphp @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace App\InvCtor; +namespace App\InvConstructor; -// An invariant class: not variance-erased (the ctor param keeps its concrete +// An invariant class: not variance-erased (the constructor param keeps its concrete // type) and its `final` modifier is preserved (no edges → no LSP hazard). final class Holder { diff --git a/test/fixture/compile/generic_invariant_ctor/source/Use.xphp b/test/fixture/compile/generic_invariant_constructor/source/Use.xphp similarity index 70% rename from test/fixture/compile/generic_invariant_ctor/source/Use.xphp rename to test/fixture/compile/generic_invariant_constructor/source/Use.xphp index 6a61236..aec5efe 100644 --- a/test/fixture/compile/generic_invariant_ctor/source/Use.xphp +++ b/test/fixture/compile/generic_invariant_constructor/source/Use.xphp @@ -2,6 +2,6 @@ declare(strict_types=1); -namespace App\InvCtor; +namespace App\InvConstructor; $h = new Holder::(new Apple()); diff --git a/test/fixture/compile/generic_two_covariant_ctor/source/Apple.xphp b/test/fixture/compile/generic_mixed_variance_constructor/source/Apple.xphp similarity index 60% rename from test/fixture/compile/generic_two_covariant_ctor/source/Apple.xphp rename to test/fixture/compile/generic_mixed_variance_constructor/source/Apple.xphp index 2a04eae..a071849 100644 --- a/test/fixture/compile/generic_two_covariant_ctor/source/Apple.xphp +++ b/test/fixture/compile/generic_mixed_variance_constructor/source/Apple.xphp @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\TwoCtor; +namespace App\MixedConstructor; class Apple { diff --git a/test/fixture/compile/generic_mixed_variance_ctor/source/Pair.xphp b/test/fixture/compile/generic_mixed_variance_constructor/source/Pair.xphp similarity index 92% rename from test/fixture/compile/generic_mixed_variance_ctor/source/Pair.xphp rename to test/fixture/compile/generic_mixed_variance_constructor/source/Pair.xphp index b3def14..564ed93 100644 --- a/test/fixture/compile/generic_mixed_variance_ctor/source/Pair.xphp +++ b/test/fixture/compile/generic_mixed_variance_constructor/source/Pair.xphp @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\MixedCtor; +namespace App\MixedConstructor; // `Pair<+A, B>`: the covariant `A` and the invariant `B` constructor params both // keep their concrete substituted types; the scalar `int $tag` is untouched. diff --git a/test/fixture/compile/generic_mixed_variance_ctor/source/Use.xphp b/test/fixture/compile/generic_mixed_variance_constructor/source/Use.xphp similarity index 74% rename from test/fixture/compile/generic_mixed_variance_ctor/source/Use.xphp rename to test/fixture/compile/generic_mixed_variance_constructor/source/Use.xphp index 3d38b5a..c795802 100644 --- a/test/fixture/compile/generic_mixed_variance_ctor/source/Use.xphp +++ b/test/fixture/compile/generic_mixed_variance_constructor/source/Use.xphp @@ -2,6 +2,6 @@ declare(strict_types=1); -namespace App\MixedCtor; +namespace App\MixedConstructor; $p = new Pair::(new Apple(), new Apple(), 5); diff --git a/test/fixture/compile/generic_mixed_variance_ctor/source/Apple.xphp b/test/fixture/compile/generic_two_covariant_constructor/source/Apple.xphp similarity index 62% rename from test/fixture/compile/generic_mixed_variance_ctor/source/Apple.xphp rename to test/fixture/compile/generic_two_covariant_constructor/source/Apple.xphp index 1c08298..6793f22 100644 --- a/test/fixture/compile/generic_mixed_variance_ctor/source/Apple.xphp +++ b/test/fixture/compile/generic_two_covariant_constructor/source/Apple.xphp @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\MixedCtor; +namespace App\TwoConstructor; class Apple { diff --git a/test/fixture/compile/generic_two_covariant_ctor/source/Two.xphp b/test/fixture/compile/generic_two_covariant_constructor/source/Two.xphp similarity index 78% rename from test/fixture/compile/generic_two_covariant_ctor/source/Two.xphp rename to test/fixture/compile/generic_two_covariant_constructor/source/Two.xphp index 7fe3f1f..b2d48ae 100644 --- a/test/fixture/compile/generic_two_covariant_ctor/source/Two.xphp +++ b/test/fixture/compile/generic_two_covariant_constructor/source/Two.xphp @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace App\TwoCtor; +namespace App\TwoConstructor; // Two covariant params: BOTH `T`-typed constructor params keep their real -// substituted types (nothing is erased for any variant ctor param). +// substituted types (nothing is erased for any variant constructor param). class Two<+A, +B> { private array $slots; diff --git a/test/fixture/compile/generic_two_covariant_ctor/source/Use.xphp b/test/fixture/compile/generic_two_covariant_constructor/source/Use.xphp similarity index 75% rename from test/fixture/compile/generic_two_covariant_ctor/source/Use.xphp rename to test/fixture/compile/generic_two_covariant_constructor/source/Use.xphp index f23e3e4..d2ee521 100644 --- a/test/fixture/compile/generic_two_covariant_ctor/source/Use.xphp +++ b/test/fixture/compile/generic_two_covariant_constructor/source/Use.xphp @@ -2,6 +2,6 @@ declare(strict_types=1); -namespace App\TwoCtor; +namespace App\TwoConstructor; $t = new Two::(new Apple(), new Apple()); From 963aa42152a427596c46198fa7b45ecb0759bffb Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 21 Jun 2026 15:01:49 +0000 Subject: [PATCH 049/114] refactor: expand ad-hoc abbreviations project-wide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spell out ad-hoc domain abbreviations across src, tests, docs, fixtures, and the mutation config so identifiers and prose read clearly: - ctor -> constructor (incl. InnerVarianceValidator::isExemptVariantCtorParam -> isExemptVariantConstructorParam, + infection/ADR refs) - Cov/Inv/Contra -> Covariant/Invariant/Contravariant (variance-kind shorthand, mostly RegistryInnerVarianceTest comments) - Lst -> Collection (`List` is reserved in PHP; nested_instantiation fixture class + regenerated snapshots, and inline FQN strings) - iface -> interface - spec -> specialization (specDir, child/base/derivedSpec, $spec — ad-hoc only) - fixture namespaces App\InvConstructor -> App\InvariantConstructor and App\ContraVarianceConstructor -> App\ContravariantConstructor Conventional abbreviations (Fqn, arg/param, ast, idx) and acronyms are kept by design. No behavioural change; full suite green, PHPStan L9 clean, 100% MSI. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...nstructor-parameters-on-variant-classes.md | 2 +- docs/roadmap.md | 2 +- infection.json5 | 6 +- .../Monomorphize/CallSiteRewriter.php | 2 +- .../Monomorphize/ClosureDispatcher.php | 6 +- .../Monomorphize/InnerVarianceValidator.php | 14 ++-- src/Transpiler/Monomorphize/TypeHierarchy.php | 8 +-- .../VariancePositionValidator.php | 2 +- .../GenericInterfaceIntegrationTest.php | 14 ++-- .../GenericMethodIntegrationTest.php | 24 +++---- .../NestedGenericsIntegrationTest.php | 26 +++---- .../RegistryInnerVarianceTest.php | 72 +++++++++---------- test/Transpiler/Monomorphize/RegistryTest.php | 20 +++--- test/Transpiler/Monomorphize/TypeRefTest.php | 10 +-- .../VarianceEdgeIntegrationTest.php | 24 +++---- .../VariancePositionPhaseTest.php | 2 +- .../Monomorphize/VisitorGuardsTest.php | 6 +- .../Monomorphize/XphpSourceParserTest.php | 26 +++---- .../source/Banana.xphp | 2 +- .../source/Consumer.xphp | 2 +- .../source/Fruit.xphp | 2 +- .../source/Use.xphp | 2 +- .../verify/specialized_interface_runtime.php | 6 +- .../source/Apple.xphp | 2 +- .../source/Holder.xphp | 2 +- .../source/Use.xphp | 2 +- .../Containers/{Lst.xphp => Collection.xphp} | 2 +- .../nested_instantiation/source/Use.xphp | 6 +- .../Box_Collection_Plastic.expected.php | 17 +++++ .../Box_Lst_Plastic.expected.php | 17 ----- ...ed.php => Collection_Plastic.expected.php} | 6 +- .../Use.expected.php | 8 +-- 32 files changed, 171 insertions(+), 171 deletions(-) rename test/fixture/compile/nested_instantiation/source/Containers/{Lst.xphp => Collection.xphp} (93%) create mode 100644 test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Box_Collection_Plastic.expected.php delete mode 100644 test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Box_Lst_Plastic.expected.php rename test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/{Lst_Plastic.expected.php => Collection_Plastic.expected.php} (73%) diff --git a/docs/adr/0013-typed-constructor-parameters-on-variant-classes.md b/docs/adr/0013-typed-constructor-parameters-on-variant-classes.md index c09dd7d..7402332 100644 --- a/docs/adr/0013-typed-constructor-parameters-on-variant-classes.md +++ b/docs/adr/0013-typed-constructor-parameters-on-variant-classes.md @@ -77,7 +77,7 @@ parameter (`?T`, `Box`, `T|X`) are not yet supported and stay rejected. `VariancePositionValidator::checkMethod` allows a plain constructor parameter of a variant class at any variance (a promoted one stays invariant); `InnerVarianceValidator` skips a -bare variance-marked constructor parameter (`isExemptVariantCtorParam`) and still rejects +bare variance-marked constructor parameter (`isExemptVariantConstructorParam`) and still rejects the non-bare shapes. [`Specializer::specialize`](../../src/Transpiler/Monomorphize/Specializer.php) substitutes the real type into the constructor parameter — no erasure step. Tests compile a covariant `ImmutableList<+T>` and assert each specialization's constructor keeps its real diff --git a/docs/roadmap.md b/docs/roadmap.md index 69db4f0..b33c692 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -161,7 +161,7 @@ upcoming one. - `+T` and `-T` markers on type parameters. - Position rules enforced at parse time (covariant in return, - contravariant in param, both forbidden in properties, ctor, + contravariant in param, both forbidden in properties, constructor, bounds, defaults). - Subtype edges emitted between specializations (`Producer` actually extends `Producer` when diff --git a/infection.json5 b/infection.json5 index 05c9986..64ad07e 100644 --- a/infection.json5 +++ b/infection.json5 @@ -296,14 +296,14 @@ "XPHP\\Transpiler\\Monomorphize\\Specializer::specialize", "XPHP\\Transpiler\\Monomorphize\\Specializer::specializeMethod", "XPHP\\Transpiler\\Monomorphize\\Specializer::specializeFunction", - // InnerVarianceValidator::isExemptVariantCtorParam: the promoted-param + // InnerVarianceValidator::isExemptVariantConstructorParam: the promoted-param // `return false` guard is unreachable in practice — a covariant class with a // promoted (property) type-param is flagged by the variance-POSITION pass // first, and inner-variance skips position-flagged definitions, so this branch // never decides anything observable. - "XPHP\\Transpiler\\Monomorphize\\InnerVarianceValidator::isExemptVariantCtorParam", + "XPHP\\Transpiler\\Monomorphize\\InnerVarianceValidator::isExemptVariantConstructorParam", // Specializer::substituteTypeRef: dropping `return $ref` on the empty-args - // branch falls through to array_map over empty + ctor, producing an + // branch falls through to array_map over empty + constructor, producing an // equivalent TypeRef. Pure micro-optimisation, no observable difference. "XPHP\\Transpiler\\Monomorphize\\Specializer::substituteTypeRef", // ByteOffsetMap::fromReplacements: removing the `return self::identity()` diff --git a/src/Transpiler/Monomorphize/CallSiteRewriter.php b/src/Transpiler/Monomorphize/CallSiteRewriter.php index 2bf821c..8528512 100644 --- a/src/Transpiler/Monomorphize/CallSiteRewriter.php +++ b/src/Transpiler/Monomorphize/CallSiteRewriter.php @@ -69,7 +69,7 @@ public function leaveNode(Node $node): Node|int|null // (so user code's `$x instanceof OriginalName` keeps working). // Generic trait -> just drop (no instanceof against traits). if ($node instanceof Class_ || $node instanceof Interface_) { - // @infection-ignore-all — Interface_'s ctor defaults `stmts` to [] + // @infection-ignore-all — Interface_'s constructor defaults `stmts` to [] // so `['stmts' => []]` and `[]` are observationally identical. return new Interface_($node->name, ['stmts' => []], $node->getAttributes()); } diff --git a/src/Transpiler/Monomorphize/ClosureDispatcher.php b/src/Transpiler/Monomorphize/ClosureDispatcher.php index 7caeecf..e9efa14 100644 --- a/src/Transpiler/Monomorphize/ClosureDispatcher.php +++ b/src/Transpiler/Monomorphize/ClosureDispatcher.php @@ -121,7 +121,7 @@ public function dispatch( continue; } $seenTags[$tag] = true; - $spec = $this->buildSpecialization( + $specialization = $this->buildSpecialization( $template, $args, $typeParams, @@ -130,8 +130,8 @@ public function dispatch( $hashLength, $useClauses, ); - $declarations[] = $spec['function']; - $arms[] = ['tag' => $tag, 'mangledFqn' => $spec['mangledFqn']]; + $declarations[] = $specialization['function']; + $arms[] = ['tag' => $tag, 'mangledFqn' => $specialization['mangledFqn']]; } $dispatcher = $this->buildDispatcherClosure( $template, diff --git a/src/Transpiler/Monomorphize/InnerVarianceValidator.php b/src/Transpiler/Monomorphize/InnerVarianceValidator.php index 651451d..f88b3b5 100644 --- a/src/Transpiler/Monomorphize/InnerVarianceValidator.php +++ b/src/Transpiler/Monomorphize/InnerVarianceValidator.php @@ -103,25 +103,25 @@ private function collect(GenericDefinition $definition): void $declarationLine = $definition->templateAst->getStartLine(); foreach ($definition->templateAst->getMethods() as $method) { - $isCtor = $method->name->toLowerString() === '__construct'; + $isConstructor = $method->name->toLowerString() === '__construct'; foreach ($method->params as $param) { // Constructor params (promoted or not) get Invariant outer // position -- PHP's class-compat rules enforce invariance on - // ctor signatures regardless of param flavor. `getProperties()` + // constructor signatures regardless of param flavor. `getProperties()` // below skips promoted ones (they're `Param`, not `Property`), // so each promoted property is walked exactly once. // - // Exception: a non-promoted ctor param typed by a bare + // Exception: a non-promoted constructor param typed by a bare // covariant/contravariant type-param is allowed — a constructor // parameter isn't part of the externally-visible variance surface // (constructors aren't called through upcast references), and PHP // exempts `__construct` from LSP, so the real type is emitted with - // no hazard. Skip it. Inner-generic ctor params (e.g. `Container`) + // no hazard. Skip it. Inner-generic constructor params (e.g. `Container`) // are still checked, as are promoted params (they're properties). - if ($isCtor && $this->isExemptVariantCtorParam($param)) { + if ($isConstructor && $this->isExemptVariantConstructorParam($param)) { continue; } - $outerPos = $isCtor ? Variance::Invariant : Variance::Contravariant; + $outerPos = $isConstructor ? Variance::Invariant : Variance::Contravariant; if ($param->type !== null) { $this->walkPhpType($param->type, $outerPos, $label, null, null); } @@ -152,7 +152,7 @@ private function collect(GenericDefinition $definition): void * surface, and PHP exempts `__construct` from LSP), so the inner-variance walk * skips these — the real type is emitted as-is. */ - private function isExemptVariantCtorParam(Param $param): bool + private function isExemptVariantConstructorParam(Param $param): bool { if ($param->flags !== 0) { return false; // promoted param == property; stays strictly invariant. diff --git a/src/Transpiler/Monomorphize/TypeHierarchy.php b/src/Transpiler/Monomorphize/TypeHierarchy.php index 3c1ddb7..7c5b9ca 100644 --- a/src/Transpiler/Monomorphize/TypeHierarchy.php +++ b/src/Transpiler/Monomorphize/TypeHierarchy.php @@ -220,12 +220,12 @@ public function enterNode(Node $node): null if ($node->extends !== null) { $directAncestors[] = $this->resolveName($node->extends); } - foreach ($node->implements as $iface) { - $directAncestors[] = $this->resolveName($iface); + foreach ($node->implements as $interface) { + $directAncestors[] = $this->resolveName($interface); } } elseif ($node instanceof Interface_) { - foreach ($node->extends as $iface) { - $directAncestors[] = $this->resolveName($iface); + foreach ($node->extends as $interface) { + $directAncestors[] = $this->resolveName($interface); } } // Trait_ has no formal ancestors — uses-of-traits are statements inside the body diff --git a/src/Transpiler/Monomorphize/VariancePositionValidator.php b/src/Transpiler/Monomorphize/VariancePositionValidator.php index 9974ed5..525a385 100644 --- a/src/Transpiler/Monomorphize/VariancePositionValidator.php +++ b/src/Transpiler/Monomorphize/VariancePositionValidator.php @@ -211,7 +211,7 @@ private function checkMethod(ClassMethod $method): void // surface (you can't call a constructor through an upcast reference), and // PHP exempts `__construct` from LSP, so the specializer emits the real // substituted type there with no soundness or autoload hazard. A *promoted* - // ctor param is also a property, which stays strictly invariant (a `T`-typed + // constructor param is also a property, which stays strictly invariant (a `T`-typed // property would PHP-fatal across the chain regardless). $classIsVariant = $this->varianceByName !== []; foreach ($method->params as $param) { diff --git a/test/Transpiler/Monomorphize/GenericInterfaceIntegrationTest.php b/test/Transpiler/Monomorphize/GenericInterfaceIntegrationTest.php index a4ff6fb..9766ab4 100644 --- a/test/Transpiler/Monomorphize/GenericInterfaceIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericInterfaceIntegrationTest.php @@ -26,7 +26,7 @@ protected function setUp(): void { $this->sourceDir = realpath(__DIR__ . '/../../fixture/compile/generic_interface/source') ?: throw new RuntimeException('Fixture missing'); - $this->workDir = sys_get_temp_dir() . '/xphp-generic-iface-' . uniqid('', true); + $this->workDir = sys_get_temp_dir() . '/xphp-generic-interface-' . uniqid('', true); $this->targetDir = $this->workDir . '/dist'; $this->cacheDir = $this->workDir . '/.xphp-cache'; mkdir($this->workDir, 0o755, true); @@ -43,7 +43,7 @@ public function testGenericInterfaceSpecializesAndIsImplementedBySpecializedClas { $this->compile(); - $ifaceFqn = Registry::generatedFqn( + $interfaceFqn = Registry::generatedFqn( 'App\\GenericInterface\\Containers\\Container', [new TypeRef('App\\GenericInterface\\Models\\Plastic')], ); @@ -52,18 +52,18 @@ public function testGenericInterfaceSpecializesAndIsImplementedBySpecializedClas [new TypeRef('App\\GenericInterface\\Models\\Plastic')], ); - $ifaceFile = $this->fqnToPath($ifaceFqn); + $interfaceFile = $this->fqnToPath($interfaceFqn); $boxFile = $this->fqnToPath($boxFqn); - self::assertFileExists($ifaceFile, 'specialized interface must be emitted'); + self::assertFileExists($interfaceFile, 'specialized interface must be emitted'); self::assertFileExists($boxFile, 'specialized class must be emitted'); $boxContent = file_get_contents($boxFile); // Pin the implements target identity (snapshot's first-seen-order // normalization can't distinguish which specialized FQN gets used). - self::assertStringContainsString('implements \\' . $ifaceFqn, $boxContent); + self::assertStringContainsString('implements \\' . $interfaceFqn, $boxContent); $snapshotDir = __DIR__ . '/../../fixture/compile/generic_interface/verify/testGenericInterfaceSpecializesAndIsImplementedBySpecializedClass'; - SnapshotHash::assertMatches($snapshotDir . '/Container_Plastic.expected.php', file_get_contents($ifaceFile)); + SnapshotHash::assertMatches($snapshotDir . '/Container_Plastic.expected.php', file_get_contents($interfaceFile)); SnapshotHash::assertMatches($snapshotDir . '/Box_Plastic.expected.php', $boxContent); } @@ -116,7 +116,7 @@ public function testSpecializedClassIsInstanceOfOriginalInterfaceMarker(): void #[RunInSeparateProcess] public function testSpecializedClassIsInstanceOfSpecializedInterfaceAtRuntime(): void { - $fixture = CompiledFixture::compile($this->sourceDir, 'generic-iface-runtime'); + $fixture = CompiledFixture::compile($this->sourceDir, 'generic-interface-runtime'); $fixture->registerAutoload('App\\GenericInterface\\'); try { require __DIR__ . '/../../fixture/compile/generic_interface/verify/specialized_interface_runtime.php'; diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php index cab8d27..b36fba6 100644 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php @@ -1235,22 +1235,22 @@ public function makeParent(T $v): Parent_ { $generated = self::globRecursive($dir . '/.xphp-cache/Generated', '*.php'); self::assertGreaterThanOrEqual(2, count($generated)); - $childSpec = null; + $childSpecialization = null; foreach ($generated as $f) { if (str_contains($f, '/Child/T_')) { - $childSpec = file_get_contents($f); + $childSpecialization = file_get_contents($f); break; } } - self::assertIsString($childSpec, 'expected a Child specialization at /Child/T_*.php'); + self::assertIsString($childSpecialization, 'expected a Child specialization at /Child/T_*.php'); // Negative invariants kept: parent must not be misresolved // to a class FQN; no leftover turbofish marker. - self::assertStringNotContainsString('App\\PseudoParent\\parent', $childSpec); - self::assertStringNotContainsString('::<', $childSpec); + self::assertStringNotContainsString('App\\PseudoParent\\parent', $childSpecialization); + self::assertStringNotContainsString('::<', $childSpecialization); SnapshotHash::assertMatches( __DIR__ . '/GenericMethodIntegrationTest/testNewParentTurbofishCompilesEndToEnd/Child.expected.php', - $childSpec, + $childSpecialization, ); } finally { self::rrmdir($dir); @@ -1272,29 +1272,29 @@ public function testGenericMethodResolvesThroughInheritance(): void try { $generated = self::globRecursive($fixture->cacheDir . '/Generated', '*.php'); - $baseSpec = ''; - $derivedSpec = ''; + $baseSpecialization = ''; + $derivedSpecialization = ''; foreach ($generated as $f) { $content = file_get_contents($f); self::assertIsString($content); if (str_contains($f, '/Base/T_')) { - $baseSpec .= $content; + $baseSpecialization .= $content; } if (str_contains($f, '/Derived/T_')) { - $derivedSpec .= $content; + $derivedSpecialization .= $content; } } // Both `identity` specializations ( and ) land on Base. self::assertSame( 2, - preg_match_all('/function identity_T_[0-9a-f]+\(/', $baseSpec), + preg_match_all('/function identity_T_[0-9a-f]+\(/', $baseSpecialization), 'both identity specializations emitted onto the declaring Base', ); // Derived inherits them; nothing is duplicated onto the subclass. self::assertStringNotContainsString( 'identity_T_', - $derivedSpec, + $derivedSpecialization, 'subclass inherits the base specialization; no duplicate on Derived', ); diff --git a/test/Transpiler/Monomorphize/NestedGenericsIntegrationTest.php b/test/Transpiler/Monomorphize/NestedGenericsIntegrationTest.php index 4b25ddf..df6363d 100644 --- a/test/Transpiler/Monomorphize/NestedGenericsIntegrationTest.php +++ b/test/Transpiler/Monomorphize/NestedGenericsIntegrationTest.php @@ -43,36 +43,36 @@ public function testNestedInstantiationGeneratesInnerAndOuterSpecializations(): $result = $this->compile($sourceDir); - self::assertSame(2, $result->generatedCount, 'expected Lst + Box>'); + self::assertSame(2, $result->generatedCount, 'expected Collection + Box>'); $plastic = new TypeRef('App\\NestedInstantiation\\Models\\Plastic'); - $lstOfPlastic = new TypeRef('App\\NestedInstantiation\\Containers\\Lst', [$plastic]); + $collectionOfPlastic = new TypeRef('App\\NestedInstantiation\\Containers\\Collection', [$plastic]); - $lstFqn = Registry::generatedFqn('App\\NestedInstantiation\\Containers\\Lst', [$plastic]); - $boxFqn = Registry::generatedFqn('App\\NestedInstantiation\\Containers\\Box', [$lstOfPlastic]); + $collectionFqn = Registry::generatedFqn('App\\NestedInstantiation\\Containers\\Collection', [$plastic]); + $boxFqn = Registry::generatedFqn('App\\NestedInstantiation\\Containers\\Box', [$collectionOfPlastic]); - $lstFile = $this->fqnToPath($lstFqn); + $collectionFile = $this->fqnToPath($collectionFqn); $boxFile = $this->fqnToPath($boxFqn); - self::assertFileExists($lstFile, "expected {$lstFile}"); + self::assertFileExists($collectionFile, "expected {$collectionFile}"); self::assertFileExists($boxFile, "expected {$boxFile}"); - $lstContent = file_get_contents($lstFile); + $collectionContent = file_get_contents($collectionFile); $boxContent = file_get_contents($boxFile); - // Pin parent identities for the nested instantiation: Box>'s - // `$item` must point at the Lst specialization specifically. - self::assertStringContainsString('public \\' . $lstFqn . ' $item', $boxContent); + // Pin parent identities for the nested instantiation: Box>'s + // `$item` must point at the Collection specialization specifically. + self::assertStringContainsString('public \\' . $collectionFqn . ' $item', $boxContent); $useFile = $this->targetDir . '/Use.php'; self::assertFileExists($useFile); $useContent = file_get_contents($useFile); // Pin which specialized FQN each `new` call refers to. self::assertStringContainsString('new \\' . $boxFqn . '()', $useContent); - self::assertStringContainsString('new \\' . $lstFqn . '()', $useContent); + self::assertStringContainsString('new \\' . $collectionFqn . '()', $useContent); $snapshotDir = __DIR__ . '/../../fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations'; - SnapshotHash::assertMatches($snapshotDir . '/Lst_Plastic.expected.php', $lstContent); - SnapshotHash::assertMatches($snapshotDir . '/Box_Lst_Plastic.expected.php', $boxContent); + SnapshotHash::assertMatches($snapshotDir . '/Collection_Plastic.expected.php', $collectionContent); + SnapshotHash::assertMatches($snapshotDir . '/Box_Collection_Plastic.expected.php', $boxContent); SnapshotHash::assertMatches($snapshotDir . '/Use.expected.php', $useContent); $this->assertAllSyntacticallyValid(); diff --git a/test/Transpiler/Monomorphize/RegistryInnerVarianceTest.php b/test/Transpiler/Monomorphize/RegistryInnerVarianceTest.php index ae56f61..d105276 100644 --- a/test/Transpiler/Monomorphize/RegistryInnerVarianceTest.php +++ b/test/Transpiler/Monomorphize/RegistryInnerVarianceTest.php @@ -214,8 +214,8 @@ public function testCovariantOuterInContravariantInnerSlotIsRejected(): void { // class Sink<-X> {} // class P<+T> { function f(): Sink } - // Outer pos = Cov; inner slot = Contra. - // effective = flip(Cov) = Contra; +T not in {Inv, Contra} -> reject. + // Outer pos = Covariant; inner slot = Contravariant. + // effective = flip(Covariant) = Contravariant; +T not in {Invariant, Contravariant} -> reject. $registry = $this->registryWith([ $this->makeDefinition( 'App\\Sink', @@ -246,8 +246,8 @@ public function testCovariantOuterInCovariantInnerSlotIsAccepted(): void { // class Producer<+X> {} // class P<+T> { function f(): Producer } - // Outer pos = Cov; inner slot = Cov. - // effective = Cov; +T in {Inv, Cov} -> accept. + // Outer pos = Covariant; inner slot = Covariant. + // effective = Covariant; +T in {Invariant, Covariant} -> accept. $registry = $this->registryWith([ $this->makeDefinition( 'App\\Producer', @@ -276,8 +276,8 @@ public function testContravariantPathThroughDoubleFlipIsAccepted(): void { // class Sink<-X> {} // class P<+T> { function f(Sink $x): void } - // Outer pos = Contra (param); inner slot = Contra. - // effective = flip(Contra) = Cov; +T in {Inv, Cov} -> accept. + // Outer pos = Contravariant (param); inner slot = Contravariant. + // effective = flip(Contravariant) = Covariant; +T in {Invariant, Covariant} -> accept. $registry = $this->registryWith([ $this->makeDefinition( 'App\\Sink', @@ -309,8 +309,8 @@ public function testInvariantOuterIsAcceptedInAnyInnerSlot(): void { // class Producer<+X> {} // class P { function f(): Producer } // T is Invariant - // effective for T: compose(Cov, Cov) = Cov; - // Invariant in {Inv, Cov} -> accept. + // effective for T: compose(Covariant, Covariant) = Covariant; + // Invariant in {Invariant, Covariant} -> accept. $registry = $this->registryWith([ $this->makeDefinition( 'App\\Producer', @@ -390,8 +390,8 @@ public function testTwoDeepNestingComposesAllTheWay(): void // class Container {} // Invariant // class Outer<+Y> {} // Covariant // class P<+T> { function f(): Outer> } - // Effective at T's leaf: compose(Cov, Cov) = Cov; then compose(Cov, Inv) = Inv. - // +T not in {Inv} -> reject. + // Effective at T's leaf: compose(Covariant, Covariant) = Covariant; then compose(Covariant, Invariant) = Invariant. + // +T not in {Invariant} -> reject. $registry = $this->registryWith([ $this->makeDefinition('App\\Container', 'Container', [new TypeParam('X')], new Class_(new Identifier('Container'))), $this->makeDefinition( @@ -423,8 +423,8 @@ public function testTwoDeepNestingComposesAllTheWay(): void public function testTwoDeepNestingAllCovariantIsAccepted(): void { - // Replace Container with Container<+X> -- compose(Cov, Cov) = Cov twice. - // +T in {Inv, Cov} -> accept. + // Replace Container with Container<+X> -- compose(Covariant, Covariant) = Covariant twice. + // +T in {Invariant, Covariant} -> accept. $registry = $this->registryWith([ $this->makeDefinition( 'App\\Container', @@ -505,9 +505,9 @@ public function testConstructorPromotedPropertyWithVariantInnerSlotIsRejected(): { // class Container<-X> {} // class P<+T> { function __construct(public Container $c) } - // Constructor param outer-pos is Invariant; inner slot Contra. - // compose(Invariant, Contra) = Invariant; +T not in {Inv} -> reject. - // Pins the ctor-promoted-property -> Invariant outer-pos branch. + // Constructor param outer-pos is Invariant; inner slot Contravariant. + // compose(Invariant, Contravariant) = Invariant; +T not in {Invariant} -> reject. + // Pins the constructor-promoted-property -> Invariant outer-pos branch. $registry = $this->registryWith([ $this->makeDefinition( 'App\\Container', @@ -543,8 +543,8 @@ public function testUnionTypeArmHitsInnerVarianceCheck(): void { // class Producer<-X> {} // class P<+T> { function f(): Producer|null } - // Outer pos = Cov; arm Producer's slot = Contra. - // compose(Cov, Contra) = Contra; +T not in {Inv, Contra} -> reject. + // Outer pos = Covariant; arm Producer's slot = Contravariant. + // compose(Covariant, Contravariant) = Contravariant; +T not in {Invariant, Contravariant} -> reject. $producerOrNull = new UnionType([ $this->genericName('App\\Producer', [new TypeRef('T', isTypeParam: true)]), new Identifier('null'), @@ -574,7 +574,7 @@ public function testFBoundWithContravariantInnerSlotIsRejected(): void // class Sink<-X> {} // class P<+T : Sink> {} // Bound is an Invariant outer-position (PHP class-compat); inner slot - // Contra; compose(Invariant, Contra) = Invariant; +T not in {Inv} -> + // Contravariant; compose(Invariant, Contravariant) = Invariant; +T not in {Invariant} -> // reject. Pins the type-param bound walk. $registry = $this->registryWith([ $this->makeDefinition( @@ -640,8 +640,8 @@ public function testDefaultExpressionWalkRejectsVariantInnerSlot(): void { // class Container<-X> {} // class P<+T, U = Container> {} - // Default is an Invariant outer-position; inner Container slot is Contra; - // compose(Invariant, Contra) = Invariant; +T not in {Inv} -> reject. + // Default is an Invariant outer-position; inner Container slot is Contravariant; + // compose(Invariant, Contravariant) = Invariant; +T not in {Invariant} -> reject. // Pins the type-param default walk. $registry = $this->registryWith([ $this->makeDefinition( @@ -676,8 +676,8 @@ public function testLeadingBackslashInTemplateFqnIsStripped(): void // The ltrim must strip it; otherwise the registry lookup misses the // template and we'd fall to conservative-unknown (Invariant). // Here Container's X is Covariant -- if we strip correctly, the - // composition stays Cov and accepts +T. If ltrim is removed, - // lookup misses, conservative-Inv kicks in, rejects. + // composition stays Covariant and accepts +T. If ltrim is removed, + // lookup misses, conservative-Invariant kicks in, rejects. $genericNode = new Name(['App', 'Container']); $genericNode->setAttribute(XphpSourceParser::ATTR_GENERIC_ARGS, [new TypeRef('T', isTypeParam: true)]); $genericNode->setAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN, '\\App\\Container'); @@ -703,7 +703,7 @@ public function testLeadingBackslashInTemplateFqnIsStripped(): void public function testNullableTypeWrapsInnerGenericForVarianceCheck(): void { - // class P<+T> { function f(): ?Container } where Container's X is Inv. + // class P<+T> { function f(): ?Container } where Container's X is Invariant. // The NullableType must recurse; the inner Container still triggers // the invariant rejection. Pins the NullableType walker branch. $registry = $this->registryWith([ @@ -730,9 +730,9 @@ public function testTypeRefArgsRecurseThroughKnownInnerTemplate(): void { // BoundLeaf -> TypeRef('App\\Container', [TypeRef('App\\Producer', [T])]) // outer pos via bound = Invariant - // Container's X = Cov -> compose(Inv, Cov) = Inv - // Producer's X = Inv -> compose(Inv, Inv) = Inv - // +T not in {Inv} -> reject. + // Container's X = Covariant -> compose(Invariant, Covariant) = Invariant + // Producer's X = Invariant -> compose(Invariant, Invariant) = Invariant + // +T not in {Invariant} -> reject. // Pins the TypeRef recursion path AND the inner-def lookup via TypeRef name. $registry = $this->registryWith([ $this->makeDefinition( @@ -801,8 +801,8 @@ public function testLeadingBackslashInTypeRefNameIsStripped(): void // The OUTER Outer lookup uses ATTR_TEMPLATE_FQN (walkPhpType branch); // the INNER `\App\Inner` lookup goes through walkTypeRef which uses // ltrim on `$ref->name`. If ltrim is dropped, the Inner lookup misses, - // conservative-Inv kicks in, compose(Cov, Inv) = Inv, rejects. - // With ltrim intact, Inner is found, Cov*Cov*Cov = Cov, accepts. + // conservative-Invariant kicks in, compose(Covariant, Invariant) = Invariant, rejects. + // With ltrim intact, Inner is found, Covariant*Covariant*Covariant = Covariant, accepts. $registry = $this->registryWith([ $this->makeDefinition( 'App\\Outer', @@ -873,8 +873,8 @@ public function testInvariantDeclaredInInvariantPositionIsAccepted(): void { // class Container {} // X is Invariant // class P { function f(): Container } // U is Invariant - // U is Invariant declared; outer pos Cov; inner slot Inv; - // compose(Cov, Inv) = Inv; Invariant in {Inv} -> accept. + // U is Invariant declared; outer pos Covariant; inner slot Invariant; + // compose(Covariant, Invariant) = Invariant; Invariant in {Invariant} -> accept. // The other type-param +T gives buildVarianceMap a non-empty map so // the walk actually runs. Pins the // `Variance::Invariant => [Variance::Invariant]` allowed-list. @@ -904,9 +904,9 @@ public function testInvariantDeclaredInCovariantPositionIsAccepted(): void { // class Producer<+X> {} // class P { function f(): Producer } - // U is Invariant declared; outer pos Cov; inner slot Cov; - // compose(Cov, Cov) = Cov; Invariant in {Inv, Cov} -> accept. - // Pins the second item of `Variance::Covariant => [Inv, Cov]` allowed-list. + // U is Invariant declared; outer pos Covariant; inner slot Covariant; + // compose(Covariant, Covariant) = Covariant; Invariant in {Invariant, Covariant} -> accept. + // Pins the second item of `Variance::Covariant => [Invariant, Covariant]` allowed-list. $registry = $this->registryWith([ $this->makeDefinition( 'App\\Producer', @@ -938,9 +938,9 @@ public function testInvariantDeclaredInContravariantPositionIsAccepted(): void { // class Producer<+X> {} // class P { function f(Producer $x): void } - // U is Invariant declared; outer pos Contra (param); inner Cov; - // compose(Contra, Cov) = Contra; Invariant in {Inv, Contra} -> accept. - // Pins the first item of `Variance::Contravariant => [Inv, Contra]` allowed-list. + // U is Invariant declared; outer pos Contravariant (param); inner Covariant; + // compose(Contravariant, Covariant) = Contravariant; Invariant in {Invariant, Contravariant} -> accept. + // Pins the first item of `Variance::Contravariant => [Invariant, Contravariant]` allowed-list. $registry = $this->registryWith([ $this->makeDefinition( 'App\\Producer', diff --git a/test/Transpiler/Monomorphize/RegistryTest.php b/test/Transpiler/Monomorphize/RegistryTest.php index 20f208d..28162f0 100644 --- a/test/Transpiler/Monomorphize/RegistryTest.php +++ b/test/Transpiler/Monomorphize/RegistryTest.php @@ -54,7 +54,7 @@ public function testSameArgShortNameDifferentNamespacesDoNotCollide(): void public function testNestedGenericArgsAffectHashDeterministically(): void { - $nested = new TypeRef('App\\RegistryTest\\Containers\\Lst', [new TypeRef('App\\RegistryTest\\Models\\Plastic')]); + $nested = new TypeRef('App\\RegistryTest\\Containers\\Collection', [new TypeRef('App\\RegistryTest\\Models\\Plastic')]); $a = Registry::generatedFqn('App\\RegistryTest\\Containers\\Box', [$nested]); $b = Registry::generatedFqn('App\\RegistryTest\\Containers\\Box', [$nested]); @@ -91,23 +91,23 @@ public function testRecordInstantiationRecursivelyRegistersNestedInstantiations( { $registry = new Registry(); - $nested = new TypeRef('App\\RegistryTest\\Containers\\Lst', [new TypeRef('App\\RegistryTest\\Models\\Plastic')]); + $nested = new TypeRef('App\\RegistryTest\\Containers\\Collection', [new TypeRef('App\\RegistryTest\\Models\\Plastic')]); $registry->recordInstantiation('App\\RegistryTest\\Containers\\Box', [$nested]); self::assertCount(2, $registry->instantiations()); $hasBox = false; - $hasLst = false; + $hasCollection = false; foreach ($registry->instantiations() as $fqn => $_) { if (str_starts_with($fqn, 'XPHP\\Generated\\App\\RegistryTest\\Containers\\Box\\T_')) { $hasBox = true; } - if (str_starts_with($fqn, 'XPHP\\Generated\\App\\RegistryTest\\Containers\\Lst\\T_')) { - $hasLst = true; + if (str_starts_with($fqn, 'XPHP\\Generated\\App\\RegistryTest\\Containers\\Collection\\T_')) { + $hasCollection = true; } } self::assertTrue($hasBox, 'expected an outer Box specialization'); - self::assertTrue($hasLst, 'expected a transitive Lst specialization'); + self::assertTrue($hasCollection, 'expected a transitive Collection specialization'); } public function testCustomHashLengthShortensClassName(): void @@ -373,12 +373,12 @@ public function testToArrayEmitsExpectedShapeForDefinitionsAndInstantiations(): public function testToArraySerializesNestedGenericArgAsDisplayString(): void { $registry = new Registry(); - $lstOfPlastic = new TypeRef('App\\RegistryTest\\Containers\\Lst', [new TypeRef('App\\RegistryTest\\Models\\Plastic')]); - $registry->recordInstantiation('App\\RegistryTest\\Containers\\Box', [$lstOfPlastic]); + $collectionOfPlastic = new TypeRef('App\\RegistryTest\\Containers\\Collection', [new TypeRef('App\\RegistryTest\\Models\\Plastic')]); + $registry->recordInstantiation('App\\RegistryTest\\Containers\\Box', [$collectionOfPlastic]); $out = $registry->toArray(); - // Find the outer Box> entry; concreteTypes should be the angle-bracket form. + // Find the outer Box> entry; concreteTypes should be the angle-bracket form. $boxEntry = null; foreach ($out['instantiations'] as $entry) { if ($entry['template'] === 'App\\RegistryTest\\Containers\\Box') { @@ -388,7 +388,7 @@ public function testToArraySerializesNestedGenericArgAsDisplayString(): void } self::assertNotNull($boxEntry); self::assertSame( - ['App\\RegistryTest\\Containers\\Lst'], + ['App\\RegistryTest\\Containers\\Collection'], $boxEntry['concreteTypes'], ); } diff --git a/test/Transpiler/Monomorphize/TypeRefTest.php b/test/Transpiler/Monomorphize/TypeRefTest.php index b7f57d9..5a1656b 100644 --- a/test/Transpiler/Monomorphize/TypeRefTest.php +++ b/test/Transpiler/Monomorphize/TypeRefTest.php @@ -48,10 +48,10 @@ public function testCanonicalFormatsMultiArgGenericWithCommaSeparator(): void public function testCanonicalFormatsNestedGenericRecursively(): void { - $inner = new TypeRef('App\\Containers\\Lst', [new TypeRef('App\\Models\\Plastic')]); + $inner = new TypeRef('App\\Containers\\Collection', [new TypeRef('App\\Models\\Plastic')]); $outer = new TypeRef('App\\Containers\\Box', [$inner]); self::assertSame( - 'App\\Containers\\Box>', + 'App\\Containers\\Box>', $outer->canonical(), ); } @@ -89,10 +89,10 @@ public function testToDisplayStringMultiArgUsesCommaSpaceSeparator(): void public function testToDisplayStringNestedGeneric(): void { - $inner = new TypeRef('App\\Containers\\Lst', [new TypeRef('App\\Models\\Plastic')]); + $inner = new TypeRef('App\\Containers\\Collection', [new TypeRef('App\\Models\\Plastic')]); $outer = new TypeRef('App\\Containers\\Box', [$inner]); self::assertSame( - 'App\\Containers\\Box>', + 'App\\Containers\\Box>', $outer->toDisplayString(), ); } @@ -120,7 +120,7 @@ public function testIsConcreteRecursesIntoArgsAndDetectsUnresolvedTypeParam(): v public function testIsConcreteIsTrueWhenAllNestedArgsAreConcrete(): void { - $inner = new TypeRef('App\\Lst', [new TypeRef('App\\Plastic')]); + $inner = new TypeRef('App\\Collection', [new TypeRef('App\\Plastic')]); $outer = new TypeRef('App\\Box', [$inner]); self::assertTrue($outer->isConcrete()); } diff --git a/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php b/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php index 9b40e92..2cf4a67 100644 --- a/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php +++ b/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php @@ -50,8 +50,8 @@ public function testCovariantImmutableCollectionTakesTypedConstructorInput(): vo 'variance-covariant-constructor', ); try { - $specDir = $fixture->cacheDir . '/Generated/App/CovariantConstructor/ImmutableList'; - $files = glob($specDir . '/T_*.php') ?: []; + $specializationDir = $fixture->cacheDir . '/Generated/App/CovariantConstructor/ImmutableList'; + $files = glob($specializationDir . '/T_*.php') ?: []; self::assertCount(2, $files, 'two ImmutableList specializations (Fruit, Banana)'); $combined = ''; @@ -115,10 +115,10 @@ public function testContravariantConstructorParamKeepsConcreteType(): void // too, and the contravariant edge (Consumer extends Consumer) // stays valid because constructors are LSP-exempt. $generated = $this->compileFixtureAndReadGenerated('compile/generic_contravariant_constructor/source'); - self::assertSame(1, preg_match_all('/function __construct\(\\\\App\\\\ContraVarianceConstructor\\\\Banana \.\.\.\$items\)/', $generated)); - self::assertSame(1, preg_match_all('/function __construct\(\\\\App\\\\ContraVarianceConstructor\\\\Fruit \.\.\.\$items\)/', $generated)); + self::assertSame(1, preg_match_all('/function __construct\(\\\\App\\\\ContravariantConstructor\\\\Banana \.\.\.\$items\)/', $generated)); + self::assertSame(1, preg_match_all('/function __construct\(\\\\App\\\\ContravariantConstructor\\\\Fruit \.\.\.\$items\)/', $generated)); self::assertStringNotContainsString('mixed ...$items', $generated); - self::assertStringContainsString('extends \\XPHP\\Generated\\App\\ContraVarianceConstructor\\Consumer\\T_', $generated); + self::assertStringContainsString('extends \\XPHP\\Generated\\App\\ContravariantConstructor\\Consumer\\T_', $generated); } public function testNonBareVariantConstructorParamShapesAreRejected(): void @@ -155,7 +155,7 @@ public function testInvariantClassConstructorIsNotErasedAndKeepsFinal(): void // type) and its `final` modifier is preserved (no edges → no LSP hazard). $generated = $this->compileFixtureAndReadGenerated('compile/generic_invariant_constructor/source'); self::assertStringContainsString('final class', $generated); - self::assertStringContainsString('App\\InvConstructor\\Apple $item', $generated); + self::assertStringContainsString('App\\InvariantConstructor\\Apple $item', $generated); self::assertStringNotContainsString('mixed $item', $generated); } @@ -280,11 +280,11 @@ public function testContravariantConstructorChainAutoloadsAndConstructsWithoutPh ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); $compiler->compile($sources, $src, $this->targetDir, $this->cacheDir); - $bananaFqn = Registry::generatedFqn('App\\ContraVarianceConstructor\\Consumer', [new TypeRef('App\\ContraVarianceConstructor\\Banana')]); - $fruitFqn = Registry::generatedFqn('App\\ContraVarianceConstructor\\Consumer', [new TypeRef('App\\ContraVarianceConstructor\\Fruit')]); + $bananaFqn = Registry::generatedFqn('App\\ContravariantConstructor\\Consumer', [new TypeRef('App\\ContravariantConstructor\\Banana')]); + $fruitFqn = Registry::generatedFqn('App\\ContravariantConstructor\\Consumer', [new TypeRef('App\\ContravariantConstructor\\Fruit')]); $prefixes = [ 'XPHP\\Generated\\' => $this->cacheDir . '/Generated', - 'App\\ContraVarianceConstructor\\' => $this->targetDir, + 'App\\ContravariantConstructor\\' => $this->targetDir, ]; $loader = $this->workDir . '/contra-load.php'; @@ -297,8 +297,8 @@ public function testContravariantConstructorChainAutoloadsAndConstructsWithoutPh . " }\n" . " }\n" . "});\n" - . "new (" . var_export($bananaFqn, true) . ")(new \\App\\ContraVarianceConstructor\\Banana());\n" - . "new (" . var_export($fruitFqn, true) . ")(new \\App\\ContraVarianceConstructor\\Fruit());\n" + . "new (" . var_export($bananaFqn, true) . ")(new \\App\\ContravariantConstructor\\Banana());\n" + . "new (" . var_export($fruitFqn, true) . ")(new \\App\\ContravariantConstructor\\Fruit());\n" . "echo \"OK\\n\";\n"; file_put_contents($loader, $script); @@ -514,7 +514,7 @@ public function testInterfaceSpecializationsGetMultiExtendsButFilterTransitives( // extends [Apple] only, NOT [Apple, Fruit]. The filter-direct-supers // pass is exercised here on a multi-extends path that single-extends // Class_ tests don't cover. - $sourceDir = $this->workDir . '/src-iface-transitive'; + $sourceDir = $this->workDir . '/src-interface-transitive'; mkdir($sourceDir, 0o755, true); file_put_contents($sourceDir . '/IProducer.xphp', <<<'PHP' [ "\n{\n public function __construct(public T \$item) {}\n}\n", diff --git a/test/Transpiler/Monomorphize/VisitorGuardsTest.php b/test/Transpiler/Monomorphize/VisitorGuardsTest.php index 8d02232..7bca360 100644 --- a/test/Transpiler/Monomorphize/VisitorGuardsTest.php +++ b/test/Transpiler/Monomorphize/VisitorGuardsTest.php @@ -427,7 +427,7 @@ public function testSpecializerNormalizesLeadingBackslashForGenericSubstitution( 'stmts' => [new Property(0, [new PropertyItem('a')], type: new Name('T'))], ]); - $genericSubst = new TypeRef('\\App\\Containers\\Lst', [new TypeRef('App\\Models\\Plastic')]); + $genericSubst = new TypeRef('\\App\\Containers\\Collection', [new TypeRef('App\\Models\\Plastic')]); $specialized = (new Specializer())->specialize($template, [ 'T' => $genericSubst, ]); @@ -435,9 +435,9 @@ public function testSpecializerNormalizesLeadingBackslashForGenericSubstitution( $nameNode = $specialized->stmts[0]->type; self::assertInstanceOf(Name::class, $nameNode); self::assertNotInstanceOf(FullyQualified::class, $nameNode); - self::assertSame('App\\Containers\\Lst', $nameNode->toString(), 'Name() ctor must receive ltrim-normalized name (line 80)'); + self::assertSame('App\\Containers\\Collection', $nameNode->toString(), 'Name() constructor must receive ltrim-normalized name (line 80)'); self::assertSame( - 'App\\Containers\\Lst', + 'App\\Containers\\Collection', $nameNode->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN), 'ATTR_TEMPLATE_FQN attribute must be ltrim-normalized (line 82)', ); diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index c05bcf5..ec1a38e 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -47,11 +47,11 @@ public function get(): T; $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); $ast = $parser->parse($source); - $iface = self::findFirstClassLike($ast, \PhpParser\Node\Stmt\Interface_::class); - self::assertNotNull($iface); - self::assertSame('Container', $iface->name?->toString()); - self::assertSame(['T'], self::paramNames($iface)); - self::assertSame('App\\Container', $iface->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN)); + $interface = self::findFirstClassLike($ast, \PhpParser\Node\Stmt\Interface_::class); + self::assertNotNull($interface); + self::assertSame('Container', $interface->name?->toString()); + self::assertSame(['T'], self::paramNames($interface)); + self::assertSame('App\\Container', $interface->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN)); } public function testGenericTraitTemplateIsDroppedFromOutputWithoutBecomingAMarker(): void @@ -635,14 +635,14 @@ public function testHandlesNestedGenericArgs(): void namespace App; use App\Containers\Box; -use App\Containers\Lst; +use App\Containers\Collection; use App\Models\Plastic; -$x = new Box::>(); +$x = new Box::>(); PHP; $args = self::parseAndGetArgs($source, 'Box'); self::assertCount(1, $args); - self::assertSame('App\\Containers\\Lst', $args[0]->name); + self::assertSame('App\\Containers\\Collection', $args[0]->name); self::assertTrue($args[0]->isGeneric()); self::assertCount(1, $args[0]->args); self::assertSame('App\\Models\\Plastic', $args[0]->args[0]->name); @@ -1019,19 +1019,19 @@ public function testFullyQualifiedTemplateNameIsRecognized(): void public function testFullyQualifiedNamesInNestedGenericArg(): void { - // `new Box<\Vendor\Lst<\Vendor\Plastic>>()` — nested generic where BOTH the + // `new Box<\Vendor\Collection<\Vendor\Plastic>>()` — nested generic where BOTH the // outer-arg template and the inner-arg type use the fully-qualified form. // Exercises the $isFq branch on line 218 (the recursive-into-generic return). $source = <<<'PHP' >(); +$x = new Box::<\Vendor\Collection<\Vendor\Plastic>>(); PHP; $args = self::parseAndGetArgs($source, 'Box'); self::assertCount(1, $args); self::assertTrue($args[0]->isGeneric()); - self::assertSame('Vendor\\Lst', $args[0]->name); + self::assertSame('Vendor\\Collection', $args[0]->name); self::assertSame('Vendor\\Plastic', $args[0]->args[0]->name); } @@ -2198,8 +2198,8 @@ public function current(): V; PHP; $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); $ast = $parser->parse($source); - $iface = self::findFirstClassLike($ast, \PhpParser\Node\Stmt\Interface_::class); - $params = $iface?->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + $interface = self::findFirstClassLike($ast, \PhpParser\Node\Stmt\Interface_::class); + $params = $interface?->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); self::assertSame(Variance::Invariant, $params[0]->variance); self::assertSame(Variance::Covariant, $params[1]->variance); } diff --git a/test/fixture/compile/generic_contravariant_constructor/source/Banana.xphp b/test/fixture/compile/generic_contravariant_constructor/source/Banana.xphp index ff25434..77358bd 100644 --- a/test/fixture/compile/generic_contravariant_constructor/source/Banana.xphp +++ b/test/fixture/compile/generic_contravariant_constructor/source/Banana.xphp @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\ContraVarianceConstructor; +namespace App\ContravariantConstructor; class Banana extends Fruit { diff --git a/test/fixture/compile/generic_contravariant_constructor/source/Consumer.xphp b/test/fixture/compile/generic_contravariant_constructor/source/Consumer.xphp index 0fc5ea1..52c3f85 100644 --- a/test/fixture/compile/generic_contravariant_constructor/source/Consumer.xphp +++ b/test/fixture/compile/generic_contravariant_constructor/source/Consumer.xphp @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\ContraVarianceConstructor; +namespace App\ContravariantConstructor; // Contravariant constructor param: keeps its real type too. The edge flips — // `Consumer` extends `Consumer` — and the chain still loads diff --git a/test/fixture/compile/generic_contravariant_constructor/source/Fruit.xphp b/test/fixture/compile/generic_contravariant_constructor/source/Fruit.xphp index f5d9401..4948512 100644 --- a/test/fixture/compile/generic_contravariant_constructor/source/Fruit.xphp +++ b/test/fixture/compile/generic_contravariant_constructor/source/Fruit.xphp @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\ContraVarianceConstructor; +namespace App\ContravariantConstructor; class Fruit { diff --git a/test/fixture/compile/generic_contravariant_constructor/source/Use.xphp b/test/fixture/compile/generic_contravariant_constructor/source/Use.xphp index 3991b45..29c38aa 100644 --- a/test/fixture/compile/generic_contravariant_constructor/source/Use.xphp +++ b/test/fixture/compile/generic_contravariant_constructor/source/Use.xphp @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\ContraVarianceConstructor; +namespace App\ContravariantConstructor; $a = new Consumer::(new Banana()); $b = new Consumer::(new Fruit()); diff --git a/test/fixture/compile/generic_interface/verify/specialized_interface_runtime.php b/test/fixture/compile/generic_interface/verify/specialized_interface_runtime.php index d8626f3..08875ba 100644 --- a/test/fixture/compile/generic_interface/verify/specialized_interface_runtime.php +++ b/test/fixture/compile/generic_interface/verify/specialized_interface_runtime.php @@ -15,7 +15,7 @@ use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeRef; -$ifaceFqn = Registry::generatedFqn( +$interfaceFqn = Registry::generatedFqn( 'App\\GenericInterface\\Containers\\Container', [new TypeRef('App\\GenericInterface\\Models\\Plastic')], ); @@ -26,11 +26,11 @@ $box = new $boxFqn(new \App\GenericInterface\Models\Plastic('red')); -Assert::assertInstanceOf($ifaceFqn, $box); +Assert::assertInstanceOf($interfaceFqn, $box); Assert::assertSame('red', $box->get()->color); // Reflection: the interface's get() return type must be the // concrete substituted class, not the unspecialized `T`. -$returnType = (new \ReflectionMethod($ifaceFqn, 'get'))->getReturnType(); +$returnType = (new \ReflectionMethod($interfaceFqn, 'get'))->getReturnType(); Assert::assertInstanceOf(\ReflectionNamedType::class, $returnType); Assert::assertSame('App\\GenericInterface\\Models\\Plastic', $returnType->getName()); diff --git a/test/fixture/compile/generic_invariant_constructor/source/Apple.xphp b/test/fixture/compile/generic_invariant_constructor/source/Apple.xphp index 98e0ab3..e7c3f9f 100644 --- a/test/fixture/compile/generic_invariant_constructor/source/Apple.xphp +++ b/test/fixture/compile/generic_invariant_constructor/source/Apple.xphp @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\InvConstructor; +namespace App\InvariantConstructor; class Apple { diff --git a/test/fixture/compile/generic_invariant_constructor/source/Holder.xphp b/test/fixture/compile/generic_invariant_constructor/source/Holder.xphp index d06f620..ae6bd36 100644 --- a/test/fixture/compile/generic_invariant_constructor/source/Holder.xphp +++ b/test/fixture/compile/generic_invariant_constructor/source/Holder.xphp @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\InvConstructor; +namespace App\InvariantConstructor; // An invariant class: not variance-erased (the constructor param keeps its concrete // type) and its `final` modifier is preserved (no edges → no LSP hazard). diff --git a/test/fixture/compile/generic_invariant_constructor/source/Use.xphp b/test/fixture/compile/generic_invariant_constructor/source/Use.xphp index aec5efe..725a84d 100644 --- a/test/fixture/compile/generic_invariant_constructor/source/Use.xphp +++ b/test/fixture/compile/generic_invariant_constructor/source/Use.xphp @@ -2,6 +2,6 @@ declare(strict_types=1); -namespace App\InvConstructor; +namespace App\InvariantConstructor; $h = new Holder::(new Apple()); diff --git a/test/fixture/compile/nested_instantiation/source/Containers/Lst.xphp b/test/fixture/compile/nested_instantiation/source/Containers/Collection.xphp similarity index 93% rename from test/fixture/compile/nested_instantiation/source/Containers/Lst.xphp rename to test/fixture/compile/nested_instantiation/source/Containers/Collection.xphp index 8d350db..0790c20 100644 --- a/test/fixture/compile/nested_instantiation/source/Containers/Lst.xphp +++ b/test/fixture/compile/nested_instantiation/source/Containers/Collection.xphp @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\NestedInstantiation\Containers; -class Lst +class Collection { /** @var array */ public array $items = []; diff --git a/test/fixture/compile/nested_instantiation/source/Use.xphp b/test/fixture/compile/nested_instantiation/source/Use.xphp index 9659557..ffbfacf 100644 --- a/test/fixture/compile/nested_instantiation/source/Use.xphp +++ b/test/fixture/compile/nested_instantiation/source/Use.xphp @@ -5,12 +5,12 @@ declare(strict_types=1); namespace App\NestedInstantiation; use App\NestedInstantiation\Containers\Box; -use App\NestedInstantiation\Containers\Lst; +use App\NestedInstantiation\Containers\Collection; use App\NestedInstantiation\Models\Plastic; -$boxOfList = new Box::>(); +$boxOfList = new Box::>(); -$inner = new Lst::(); +$inner = new Collection::(); $inner->push(new Plastic('red')); $boxOfList->set($inner); diff --git a/test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Box_Collection_Plastic.expected.php b/test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Box_Collection_Plastic.expected.php new file mode 100644 index 0000000..76bf633 --- /dev/null +++ b/test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Box_Collection_Plastic.expected.php @@ -0,0 +1,17 @@ +item = $val; + } + public function get(): \XPHP\Generated\App\NestedInstantiation\Containers\Collection\T_72cd9917ee1636a336e65540a284a68071f850278ebd78f2f765b25f9e969418 + { + return $this->item; + } +} \ No newline at end of file diff --git a/test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Box_Lst_Plastic.expected.php b/test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Box_Lst_Plastic.expected.php deleted file mode 100644 index dee8ad6..0000000 --- a/test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Box_Lst_Plastic.expected.php +++ /dev/null @@ -1,17 +0,0 @@ -item = $val; - } - public function get(): \XPHP\Generated\App\NestedInstantiation\Containers\Lst\T_72cd9917ee1636a336e65540a284a68071f850278ebd78f2f765b25f9e969418 - { - return $this->item; - } -} diff --git a/test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Lst_Plastic.expected.php b/test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Collection_Plastic.expected.php similarity index 73% rename from test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Lst_Plastic.expected.php rename to test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Collection_Plastic.expected.php index 563165b..0229880 100644 --- a/test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Lst_Plastic.expected.php +++ b/test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Collection_Plastic.expected.php @@ -1,9 +1,9 @@ */ public array $items = []; @@ -15,4 +15,4 @@ public function first(): \App\NestedInstantiation\Models\Plastic { return $this->items[0]; } -} +} \ No newline at end of file diff --git a/test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Use.expected.php b/test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Use.expected.php index c6cff17..c89aee1 100644 --- a/test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Use.expected.php +++ b/test/fixture/compile/nested_instantiation/verify/testNestedInstantiationGeneratesInnerAndOuterSpecializations/Use.expected.php @@ -4,9 +4,9 @@ namespace App\NestedInstantiation; use App\NestedInstantiation\Containers\Box; -use App\NestedInstantiation\Containers\Lst; +use App\NestedInstantiation\Containers\Collection; use App\NestedInstantiation\Models\Plastic; -$boxOfList = new \XPHP\Generated\App\NestedInstantiation\Containers\Box\T_97c6bd3a59f7a82e44580814227a9520bc2d6551991c8263366d2d9c44f7627f(); -$inner = new \XPHP\Generated\App\NestedInstantiation\Containers\Lst\T_72cd9917ee1636a336e65540a284a68071f850278ebd78f2f765b25f9e969418(); +$boxOfList = new \XPHP\Generated\App\NestedInstantiation\Containers\Box\T_b161c2856b3c6debba523ecb74e8cefe279a8707898b647ece965cdeb420deb2(); +$inner = new \XPHP\Generated\App\NestedInstantiation\Containers\Collection\T_72cd9917ee1636a336e65540a284a68071f850278ebd78f2f765b25f9e969418(); $inner->push(new Plastic('red')); -$boxOfList->set($inner); +$boxOfList->set($inner); \ No newline at end of file From c1c8b3e7054c515a1d033ac842803fee30abb758 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 21 Jun 2026 15:06:09 +0000 Subject: [PATCH 050/114] docs: correct VariancePositionValidator docblock for real-typed constructors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The class docblock still described constructor parameters as "Invariant only" and claimed PHP applies LSP to `__construct` — stale wording left from the variance-erasure removal. Constructors are LSP-exempt: a non-promoted constructor parameter may carry any variance and is emitted with its real substituted type; only a promoted one (a property) stays strictly invariant. Comment-only; no behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/VariancePositionValidator.php | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Transpiler/Monomorphize/VariancePositionValidator.php b/src/Transpiler/Monomorphize/VariancePositionValidator.php index 525a385..c7eb543 100644 --- a/src/Transpiler/Monomorphize/VariancePositionValidator.php +++ b/src/Transpiler/Monomorphize/VariancePositionValidator.php @@ -30,7 +30,8 @@ * Position rules (PHP-compat surface): * * - Property type (mutable OR readonly) -> Invariant only - * - Constructor parameter type -> Invariant only + * - Promoted constructor parameter -> Invariant only (it is a property) + * - Non-promoted constructor parameter -> any variance (emitted with real type) * - Method/function parameter type -> Invariant or Contravariant * - Method/function return type -> Invariant or Covariant * - Bound expression -> Invariant only @@ -44,9 +45,14 @@ * rule. Users who need a covariant getter use a `mixed`-typed (or * bound-typed) backing field + a method `get(): T`. * - * Why constructors are strict-invariant: PHP applies LSP signature - * compatibility to `__construct` at autoload time on `extends` chains. - * A covariant param would PHP-fatal -- same shape as the property case. + * Why a non-promoted constructor parameter may carry any variance: a + * constructor isn't part of the externally-visible variance surface (it's + * never reached through an upcast reference, the same reason Kotlin exempts + * constructor parameters), and PHP exempts `__construct` from LSP signature + * checks, so each specialization's constructor may legitimately differ. The + * Specializer emits the real substituted type there -- nothing is erased. A + * *promoted* constructor parameter is a property, so it falls under the + * strict-invariant property rule above. * * F-bounded variance (`class Sortable<+T : Comparable>`) is rejected * because `+T` appears inside its own bound (an invariant position). From 9d310dbdddea0089be9551da5879312c697e0f77 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 21 Jun 2026 16:26:43 +0000 Subject: [PATCH 051/114] feat(monomorphize): treat by-reference parameters as an invariant position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A by-reference parameter (`function f(T &$x)`) is read AND written back through the caller's variable, so it acts as input and output at once — neither `+T` nor `-T` is sound there. The variance-position validator had no `byRef` handling, so `-T &$x` was wrongly accepted. VariancePositionValidator now rejects any variance-marked type parameter in a by-ref position (method, constructor, and nested closure/arrow signatures); InnerVarianceValidator treats a by-ref param as an invariant outer position and no longer exempts a by-ref bare-T constructor param. Docs (variance.md position table, Variance enum docblock, errors.md) note the rule. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/errors.md | 12 ++++++ docs/syntax/variance.md | 6 +++ .../Monomorphize/InnerVarianceValidator.php | 10 ++++- src/Transpiler/Monomorphize/Variance.php | 24 +++++++---- .../VariancePositionValidator.php | 18 +++++++- .../VariancePositionPhaseTest.php | 42 +++++++++++++++++++ 6 files changed, 100 insertions(+), 12 deletions(-) diff --git a/docs/errors.md b/docs/errors.md index 9fbc04c..0a79eaf 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -158,6 +158,18 @@ appears in -only position (via slot N of ). ``` +### Variance position violation + +``` +Generic parameter `<+|->T` appears in position, which is not +allowed for variance. +``` + +`` is the forbidding position — e.g. `method parameter`, +`method return`, `mutable property`, `readonly property`, or +`by-reference parameter`. A **by-reference parameter** (`T &$x`) is invariant +(it is read and written back), so neither `+T` nor `-T` is allowed there. + ### Bound violations ``` diff --git a/docs/syntax/variance.md b/docs/syntax/variance.md index 8e6b6d3..ef2291b 100644 --- a/docs/syntax/variance.md +++ b/docs/syntax/variance.md @@ -73,6 +73,7 @@ Position rules enforced at parse time: |---------------------------------------|---------------|---------------| | Method return type | ✅ | ❌ | | Method parameter | ❌ | ✅ | +| By-reference parameter (`T &$x`) | ❌ | ❌ | | Constructor parameter (plain) | ✅ | ✅ | | Mutable property | ❌ | ❌ | | Readonly property | ❌ | ❌ | @@ -86,6 +87,11 @@ promoted-constructor) is forced by the runtime model: xphp emits real property types across those chains regardless of `readonly` — a covariant property would PHP-fatal at autoload when the variance edge lands. +A **by-reference parameter** (`function f(T &$x)`) is likewise invariant: the +caller's variable is both read and written back through the reference, so it acts +as input *and* output — neither `+T` nor `-T` is sound. This holds in method, +constructor, and nested closure/arrow signatures. + A **plain (non-promoted) constructor parameter** is the exception: it may carry `+T` / `-T` at any variance, and xphp emits it with its **real** substituted type. A constructor parameter isn't part of the externally-visible diff --git a/src/Transpiler/Monomorphize/InnerVarianceValidator.php b/src/Transpiler/Monomorphize/InnerVarianceValidator.php index f88b3b5..cebaa51 100644 --- a/src/Transpiler/Monomorphize/InnerVarianceValidator.php +++ b/src/Transpiler/Monomorphize/InnerVarianceValidator.php @@ -121,7 +121,12 @@ private function collect(GenericDefinition $definition): void if ($isConstructor && $this->isExemptVariantConstructorParam($param)) { continue; } - $outerPos = $isConstructor ? Variance::Invariant : Variance::Contravariant; + // A by-reference parameter is read AND written back, so it's an + // invariant outer position regardless of method vs constructor + // (e.g. a by-ref of a covariant container `f(Container &$x)`). + $outerPos = ($isConstructor || $param->byRef) + ? Variance::Invariant + : Variance::Contravariant; if ($param->type !== null) { $this->walkPhpType($param->type, $outerPos, $label, null, null); } @@ -157,6 +162,9 @@ private function isExemptVariantConstructorParam(Param $param): bool if ($param->flags !== 0) { return false; // promoted param == property; stays strictly invariant. } + if ($param->byRef) { + return false; // by-ref is read + written back == invariant; not exempt. + } $type = $param->type; if (!$type instanceof Name) { return false; diff --git a/src/Transpiler/Monomorphize/Variance.php b/src/Transpiler/Monomorphize/Variance.php index bc457f1..998d217 100644 --- a/src/Transpiler/Monomorphize/Variance.php +++ b/src/Transpiler/Monomorphize/Variance.php @@ -10,16 +10,22 @@ * - `Invariant` (the default, no prefix): T can appear in both input and * output positions; specializations are unrelated unless their args are * identical. - * - `Covariant` (`+T`): T can appear in method return positions only. - * `T1 <: T2` lifts to `Box <: Box`. - * - `Contravariant` (`-T`): T can appear in method parameter positions only. - * `T1 <: T2` lifts to `Box <: Box` (flipped). + * - `Covariant` (`+T`): T can appear in method return positions (and in a + * plain constructor parameter — see below). `T1 <: T2` lifts to + * `Box <: Box`. + * - `Contravariant` (`-T`): T can appear in method parameter positions (and in + * a plain constructor parameter). `T1 <: T2` lifts to `Box <: Box` + * (flipped). * - * Property positions (mutable AND readonly), constructor parameters, bounds, - * and defaults are strict-invariant for both `+T` and `-T` -- PHP enforces - * invariant property types and constructor signature compat across `extends` - * chains regardless of `readonly`, so a covariance allowance there would - * PHP-fatal at autoload when the variance edge emits. + * Property positions (mutable AND readonly, including a *promoted* constructor + * parameter), bounds, and defaults are strict-invariant for both `+T` and `-T`: + * PHP enforces invariant property types across `extends` chains regardless of + * `readonly`, so a covariance allowance there would PHP-fatal at autoload when + * the variance edge emits. A **by-reference** parameter (`T &$x`) is also + * invariant — it is read and written back, acting as input and output at once. + * A plain (non-promoted, non-by-ref) constructor parameter, by contrast, may + * carry any variance: a constructor isn't part of the visible variance surface + * and PHP exempts `__construct` from LSP, so its real type is emitted. * * String-backed so the registry JSON serializes cleanly. */ diff --git a/src/Transpiler/Monomorphize/VariancePositionValidator.php b/src/Transpiler/Monomorphize/VariancePositionValidator.php index c7eb543..f02be10 100644 --- a/src/Transpiler/Monomorphize/VariancePositionValidator.php +++ b/src/Transpiler/Monomorphize/VariancePositionValidator.php @@ -228,6 +228,15 @@ private function checkMethod(ClassMethod $method): void if ($param->type === null) { continue; } + // A by-reference parameter is read AND written back through the + // caller's variable, so it's an invariant position regardless of + // method vs constructor — neither +T nor -T is sound there. Checked + // before the variant-constructor any-variance branch so `-T &$x` in + // a constructor is rejected too. + if ($param->byRef) { + $this->checkPhpType($param->type, [Variance::Invariant], 'by-reference parameter'); + continue; + } $isPromoted = $param->flags !== 0; $allowed = ($isConstructor && !$isPromoted && $classIsVariant) ? [Variance::Invariant, Variance::Covariant, Variance::Contravariant] @@ -272,10 +281,15 @@ private function walkBodyForNestedClosures(mixed $node): void foreach ($node->params as $param) { // @phpstan-ignore-next-line instanceof.alwaysTrue — defensive guard against nikic/php-parser PHPDoc-narrowed param collection element. if ($param instanceof Param && $param->type !== null) { + // A by-reference param is an invariant position (read + written + // back), even inside a nested closure/arrow. + $allowed = $param->byRef + ? [Variance::Invariant] + : [Variance::Invariant, Variance::Contravariant]; $this->checkPhpType( $param->type, - [Variance::Invariant, Variance::Contravariant], - 'nested closure/arrow parameter', + $allowed, + $param->byRef ? 'by-reference parameter' : 'nested closure/arrow parameter', ); } } diff --git a/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php b/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php index fade0e1..ff815fc 100644 --- a/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php +++ b/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php @@ -74,6 +74,20 @@ public static function rejectedSources(): iterable "\n{\n public function feed(T \$x): void;\n}\n", ['+T'], ]; + // A by-reference parameter is read AND written back, so it is an + // invariant position — neither +T nor -T is allowed there. + yield 'contravariant in by-reference parameter' => [ + "\n{\n public function swap(T &\$x): void {}\n}\n", + ['-T', 'by-reference parameter'], + ]; + yield 'covariant in by-reference parameter' => [ + "\n{\n public function swap(T &\$x): void {}\n}\n", + ['+T', 'by-reference parameter'], + ]; + yield 'contravariant in nested closure by-reference parameter' => [ + "\n{\n public function pipe(): array\n {\n \$f = function (T &\$x) {};\n return [];\n }\n}\n", + ['by-reference parameter'], + ]; } /** @@ -129,6 +143,34 @@ public function testAllViolationsAcrossDefinitionsCollectedInOneRun(): void } } + public function testByReferenceParamWithoutVarianceMarkersIsAllowed(): void + { + // An invariant class (no +T/-T) with a by-ref T parameter is fine — the + // by-ref invariance rule only constrains variance-marked type params. + $source = "\n{\n public function swap(T &\$x): void {}\n}\n"; + $registry = $this->registryFor($source); + + $registry->validateVariancePositions(); // must NOT throw + self::assertTrue(true); + } + + public function testByReferenceParamDoesNotShortCircuitLaterParams(): void + { + // A by-ref param violation must not stop the walk: a *later* violating + // param in the same signature is still reported (pins `continue`, not + // `break`, after the by-ref check). + $source = "\n{\n public function f(T &\$a, T \$b): void {}\n}\n"; + $collector = new DiagnosticCollector(); + $registry = $this->registryFor($source, $collector); + + $registry->validateVariancePositions(); + + $messages = array_map(static fn ($d): string => $d->message, $collector->all()); + self::assertCount(2, $messages); + self::assertStringContainsString('by-reference parameter', implode("\n", $messages)); + self::assertStringContainsString('method parameter', implode("\n", $messages)); + } + private function registryFor(string $source, ?DiagnosticCollector $collector = null): Registry { $ast = (new XphpSourceParser((new ParserFactory())->createForHostVersion()))->parse($source); From 7c7a133643a9804f198eb6454449b620735b00b9 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 21 Jun 2026 16:40:33 +0000 Subject: [PATCH 052/114] feat(monomorphize)!: reject `final` on a variant class instead of stripping it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `final class Box<+T>` previously compiled and the generated specialization silently dropped `final` so the `extends` subtype edge could land — which made `ReflectionClass::isFinal()` lie about the generated class. Reject `final` on a variant class at compile time instead: what you write is what you get, and a covariant collection simply omits `final`. VariancePositionValidator now records a violation for a `final` variant class (reached only for variant definitions). The now-dead silent strip is removed: Specializer drops the `final`-strip, the `hasVariantParam` helper, the unused `$typeParams` parameter, and the `Modifiers` import; Compiler's specialize() call drops the third argument. The covariant-immutable fixture is no longer `final`; docs (variance.md, ADR-0013, errors.md) describe the rejection. BREAKING CHANGE: a `final` class with a `+T`/`-T` type parameter is now a compile error (previously `final` was silently dropped). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...nstructor-parameters-on-variant-classes.md | 7 ++-- docs/errors.md | 10 ++++++ docs/syntax/variance.md | 7 ++-- src/Transpiler/Monomorphize/Compiler.php | 1 - src/Transpiler/Monomorphize/Specializer.php | 32 +++---------------- .../VariancePositionValidator.php | 15 +++++++++ .../VarianceEdgeIntegrationTest.php | 5 +-- .../VariancePositionPhaseTest.php | 6 ++++ .../source/ImmutableList.xphp | 5 +-- 9 files changed, 49 insertions(+), 39 deletions(-) diff --git a/docs/adr/0013-typed-constructor-parameters-on-variant-classes.md b/docs/adr/0013-typed-constructor-parameters-on-variant-classes.md index 7402332..03a605c 100644 --- a/docs/adr/0013-typed-constructor-parameters-on-variant-classes.md +++ b/docs/adr/0013-typed-constructor-parameters-on-variant-classes.md @@ -49,9 +49,10 @@ specializations' constructors may legitimately differ (`Banana ...$items` on the construction is runtime-type-checked** — building an `ImmutableList` from a non-`Banana` throws a `TypeError`. -The `final`-strip corollary stands: a `final` class can't be a parent in an `extends` -edge, so `final` is dropped from variant-class specializations (internal generated -classes; invisible to user code). +A corollary about `final`: a `final` class can't be a parent in an `extends` edge, so a +variant class cannot be `final`. Rather than silently strip `final` from the generated +specializations (which would make `ReflectionClass::isFinal()` lie about them), xphp +**rejects** `final` on a `+T`/`-T` class at compile time. The relaxation is narrow. **Properties stay strictly invariant** — mutable, `readonly`, and *promoted* constructor parameters (which are properties). PHP makes property types diff --git a/docs/errors.md b/docs/errors.md index 0a79eaf..46ec022 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -170,6 +170,16 @@ allowed for variance. `by-reference parameter`. A **by-reference parameter** (`T &$x`) is invariant (it is read and written back), so neither `+T` nor `-T` is allowed there. +### Variant class declared `final` + +``` +A variant class cannot be declared `final`: its specializations participate +in `extends` subtype edges that a `final` class cannot anchor. Remove `final`. +``` + +A `+T` / `-T` class is specialized into a chain of `extends`-linked classes; a +`final` class can't be a parent in that chain. Drop `final` from the declaration. + ### Bound violations ``` diff --git a/docs/syntax/variance.md b/docs/syntax/variance.md index ef2291b..7e7a8c8 100644 --- a/docs/syntax/variance.md +++ b/docs/syntax/variance.md @@ -109,7 +109,7 @@ A covariant container can take its element type in its constructor — the backbone of a read-only `List`-style collection: ```php -final class ImmutableList<+T> { +class ImmutableList<+T> { private array $items; public function __construct(T ...$items) { $this->items = $items; } public function get(int $i): T { return $this->items[$i]; } @@ -127,8 +127,9 @@ The constructor parameter keeps its real element type on every specialisation (`Book ...$items` on `ImmutableList`, `Product ...$items` on `ImmutableList`), and `ImmutableList` still `extends ImmutableList` without a PHP fatal — PHP doesn't signature-check -`__construct` across the chain. (`final` is preserved in your source; xphp drops -it only on the internal generated specialisations so the edge can land.) +`__construct` across the chain. (A variant class **cannot be declared `final`**: +its specializations are linked by `extends` edges, which a `final` class can't +anchor, so `final` on a `+T`/`-T` class is rejected at compile time.) > ✅ **Construction is runtime-type-checked.** Because the constructor parameter > keeps its real type, PHP enforces it at construction: building an diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 9176920..6b37851 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -140,7 +140,6 @@ public function compile( $specialized = $this->specializer->specialize( $definition->templateAst, $substitution, - $definition->typeParams, ); $specializedAsts[$generatedFqn] = $specialized; diff --git a/src/Transpiler/Monomorphize/Specializer.php b/src/Transpiler/Monomorphize/Specializer.php index 562fca4..c80c23e 100644 --- a/src/Transpiler/Monomorphize/Specializer.php +++ b/src/Transpiler/Monomorphize/Specializer.php @@ -7,7 +7,6 @@ use PhpParser\Node; use PhpParser\Node\Identifier; use PhpParser\Node\Name; -use PhpParser\Modifiers; use PhpParser\Node\Name\FullyQualified; use PhpParser\Node\Stmt\Class_; use PhpParser\Node\Stmt\ClassLike; @@ -41,11 +40,6 @@ final class Specializer { /** * @param array $substitution Type-param name → concrete TypeRef. - * @param list $typeParams The template's type-params. Used only to - * detect whether the class is variant, so `final` can be stripped from its - * specializations (a `final` parent can't anchor a variance `extends` edge). - * Empty (the default) keeps `final` — fine for callers, e.g. unit tests, that - * don't have the params. * * Type parameters in every position — including constructor parameters — are * substituted to their *concrete* type; nothing is erased. PHP exempts @@ -54,12 +48,14 @@ final class Specializer * variance `extends` chain, giving a real runtime type check at construction. * A `T`-typed *property* (mutable, readonly, or promoted) is the one shape that * can't cross the edge — PHP property types are invariant — and is rejected - * upstream by the variance-position validator, not erased here. + * upstream by the variance-position validator, not erased here. A `final` + * variant class is likewise rejected upstream (a `final` class can't anchor a + * variance `extends` edge), so no `final` needs stripping here. * * The cloned class's `name` is intentionally NOT set here — SpecializedClassGenerator::emit * is the single source of truth for the final shortname (derived from the generated FQCN). */ - public function specialize(ClassLike $template, array $substitution, array $typeParams = []): ClassLike + public function specialize(ClassLike $template, array $substitution): ClassLike { $originalTemplateFqn = $template->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); @@ -81,31 +77,11 @@ public function specialize(ClassLike $template, array $substitution, array $type } } - // A variant class's specializations participate in `extends` subtype - // edges (VarianceEdgeEmitter); a `final` parent in that chain would - // PHP-fatal at autoload. These generated classes are internal — user - // code references the marker interface or the turbofish call site, never - // these names — so dropping `final` here is invisible and safe. - if ($cloned instanceof Class_ && self::hasVariantParam($typeParams)) { - $cloned->flags &= ~Modifiers::FINAL; - } - self::runSubstitutingVisitor($cloned, $substitution); return $cloned; } - /** @param list $typeParams */ - private static function hasVariantParam(array $typeParams): bool - { - foreach ($typeParams as $typeParam) { - if ($typeParam->variance !== Variance::Invariant) { - return true; - } - } - return false; - } - /** * Specialize a single generic method: clone the ClassMethod, substitute every * Name reference to a type-param with the matching concrete TypeRef, drop the diff --git a/src/Transpiler/Monomorphize/VariancePositionValidator.php b/src/Transpiler/Monomorphize/VariancePositionValidator.php index f02be10..ce27401 100644 --- a/src/Transpiler/Monomorphize/VariancePositionValidator.php +++ b/src/Transpiler/Monomorphize/VariancePositionValidator.php @@ -13,6 +13,7 @@ use PhpParser\Node\Name; use PhpParser\Node\NullableType; use PhpParser\Node\Param; +use PhpParser\Node\Stmt\Class_; use PhpParser\Node\Stmt\ClassLike; use PhpParser\Node\Stmt\ClassMethod; use PhpParser\Node\Stmt\Property; @@ -143,6 +144,20 @@ private function collect(ClassLike $node, array $params): void { $declarationLine = $node->getStartLine(); + // 0. A variant class cannot be `final`. Its specializations participate + // in real `extends` subtype edges, which a `final` class cannot anchor. + // Rejecting it (rather than silently stripping `final` from the generated + // class — which would make ReflectionClass::isFinal lie) keeps source and + // emitted output honest. Only reached for variant definitions, since + // assertPositions early-returns when there are no variance markers. + if ($node instanceof Class_ && $node->isFinal()) { + // One string literal (not concatenated) keeps the message stable. + $this->record( + 'A variant class cannot be declared `final`: its specializations participate in `extends` subtype edges that a `final` class cannot anchor. Remove `final`.', + $declarationLine, + ); + } + // 1. Bound and default positions are invariant by RFC. foreach ($params as $param) { if ($param->bound !== null) { diff --git a/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php b/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php index 2cf4a67..a2f6b6e 100644 --- a/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php +++ b/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php @@ -78,8 +78,9 @@ public function testCovariantImmutableCollectionTakesTypedConstructorInput(): vo self::assertStringNotContainsString('mixed ...$items', $combined, 'nothing is erased to mixed'); // Exactly one specialization extends the other — the covariant edge. self::assertSame(1, $extendsEdges, 'ImmutableList extends ImmutableList'); - // `final` is stripped from variant-class specializations so the edge's - // parent isn't a final class (which would PHP-fatal at autoload). + // A variant class can't be `final` (rejected at compile time), so no + // specialization is `final` — the edge's parent isn't a final class + // (which would PHP-fatal at autoload). self::assertStringNotContainsString('final class', $combined); $fixture->registerAutoload('App\\CovariantConstructor'); diff --git a/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php b/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php index ff815fc..8ce3814 100644 --- a/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php +++ b/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php @@ -88,6 +88,12 @@ public static function rejectedSources(): iterable "\n{\n public function pipe(): array\n {\n \$f = function (T &\$x) {};\n return [];\n }\n}\n", ['by-reference parameter'], ]; + // A variant class can't be `final`: its specializations are linked by + // real `extends` edges, which a `final` class can't anchor. + yield 'final variant class' => [ + "\n{\n public function get(): T { throw new \\LogicException; }\n}\n", + ['cannot be declared `final`'], + ]; } /** diff --git a/test/fixture/compile/generic_covariant_immutable_constructor/source/ImmutableList.xphp b/test/fixture/compile/generic_covariant_immutable_constructor/source/ImmutableList.xphp index 73646cf..7669989 100644 --- a/test/fixture/compile/generic_covariant_immutable_constructor/source/ImmutableList.xphp +++ b/test/fixture/compile/generic_covariant_immutable_constructor/source/ImmutableList.xphp @@ -8,8 +8,9 @@ namespace App\CovariantConstructor; // constructor parameter keeps its REAL type on every specialization // (`Fruit ...` / `Banana ...`); PHP exempts `__construct` from LSP, so the // `extends` chain (ImmutableList extends ImmutableList) is -// valid and construction is runtime-type-checked. -final class ImmutableList<+T> +// valid and construction is runtime-type-checked. Not `final` — a variant class +// can't be (its specializations are linked by `extends` edges). +class ImmutableList<+T> { private array $items; From 631d466237edf208dcff8292bf2d2973d81deacf Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 21 Jun 2026 16:51:30 +0000 Subject: [PATCH 053/114] docs: fix variance covariant example + roadmap position summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - variance.md: the covariant `Producer<+T>` example used a *promoted* `private T $item` constructor property, which is an invariant position and is rejected at compile time. Rewrite it to the valid covariant pattern (a non-promoted real-typed constructor parameter + a `mixed` backing field + covariant `get(): T`), matching the canonical fixture; align the "what gets emitted" snippet to show the real-typed constructors. - roadmap.md: the Shipped > Variance summary wrongly listed "constructor" among the positions where variance is forbidden — a plain non-promoted constructor parameter now allows any variance (real-typed construction). Corrected the position list (forbidden: properties incl. promoted ctor params, by-reference params, bounds, defaults) and added the real-typed construction + `final`-variant-class-rejected bullets. - Both: "enforced at parse time" -> "at compile time" (variance position checks run as a Registry phase over collected definitions, not the parser). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/roadmap.md | 12 +++++++++--- docs/syntax/variance.md | 12 +++++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index b33c692..e7e884d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -160,9 +160,15 @@ upcoming one. ### Variance - `+T` and `-T` markers on type parameters. -- Position rules enforced at parse time (covariant in return, - contravariant in param, both forbidden in properties, constructor, - bounds, defaults). +- Position rules enforced at compile time (covariant in return, + contravariant in param, any variance in a plain non-promoted + constructor parameter; both forbidden in properties — including + promoted constructor params — by-reference params, bounds, and + defaults). +- Real-typed construction: a `+T`/`-T` constructor parameter keeps its + concrete type (nothing erased), so construction is runtime-type-checked. +- A `final` variant class is rejected (a `final` class can't anchor the + `extends` edge). - Subtype edges emitted between specializations (`Producer` actually extends `Producer` when `Banana extends Fruit`). diff --git a/docs/syntax/variance.md b/docs/syntax/variance.md index 7e7a8c8..ce60ac6 100644 --- a/docs/syntax/variance.md +++ b/docs/syntax/variance.md @@ -14,9 +14,12 @@ declare(strict_types=1); namespace App; -// Covariant: T appears in return positions only +// Covariant: T appears in return positions (and a plain constructor parameter). +// The backing field is `mixed`, not `T` — a `T`-typed *property* is invariant +// and would be rejected (see the rules below). class Producer<+T> { - public function __construct(private T $item) {} + private mixed $item; + public function __construct(T $item) { $this->item = $item; } public function get(): T { return $this->item; } } @@ -51,10 +54,12 @@ specializations: namespace XPHP\Generated\App\Producer; class T_ implements \App\Producer { + public function __construct(\App\Fruit $item) { ... } // real type, runtime-checked public function get(): \App\Fruit { ... } } class T_ extends T_ implements \App\Producer { + public function __construct(\App\Banana $item) { ... } // narrowed; `__construct` is LSP-exempt public function get(): \App\Banana { ... } } ``` @@ -67,7 +72,8 @@ For contravariant `-T`, the edge flips: `Consumer extends Consumer Date: Sun, 21 Jun 2026 16:56:41 +0000 Subject: [PATCH 054/114] docs: add caveat for covariant getters tripping the PHPStan pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A concrete covariant container stores its element in a `mixed`/`array` backing field (a `T`-typed property is invariant, so rejected) and exposes it via `get(): T`. xphp substitutes type parameters in signatures but not in method bodies, so the emitted `get(): Banana { return $this->item; }` returns `mixed` — which the optional `xphp check` PHPStan-over-output pass flags (`should return Banana but returns mixed`). The generics compile and run correctly; this is the PHPStan pass being stricter, and it affects any covariant getter-over-storage (including the docs' own examples). Adds a caveats.md entry (with `--no-phpstan` / PHPStan-ignore / in-body-assert workarounds) and a cross-reference from the variance construction note. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/caveats.md | 52 +++++++++++++++++++++++++++++++++++++++++ docs/syntax/variance.md | 4 +++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/docs/caveats.md b/docs/caveats.md index 4e1bb8a..6c01a14 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -366,6 +366,58 @@ can see it. --- +## Covariant getters trip the `xphp check` PHPStan pass + +### ❌ What gets flagged + +```php +class Producer<+T> { + private mixed $item; // backing field can't be `T` (properties are invariant) + public function __construct(T $item) { $this->item = $item; } + public function get(): T { return $this->item; } +} + +$p = new Producer::(new Banana()); // compiles + runs fine +``` + +`xphp compile` is happy and the runtime is correct, but `xphp check`'s +optional [PHPStan-over-the-compiled-output pass](errors.md#phpstan-over-the-compiled-output) +reports, on the `Producer` specialization: + +``` +Method ...\Producer\T_::get() should return App\Banana but returns mixed. +[phpstan.return.type] +``` + +### Why + +A covariant container can't store its element in a `T`-typed **property** (PHP +property types are invariant across the `extends` edge — see +[variance markers are class-level only](#variance-markers-are-class-level-only) +above and the [variance](syntax/variance.md) rules), so the backing field is +`mixed`/`array`. xphp substitutes type parameters in **signatures** (the emitted +`get(): Banana` is correct), but **not inside method bodies** — `return +$this->item` still reads a `mixed` field. PHPStan, analysing the concrete output, +sees `mixed` returned where `Banana` is declared and reports it. This is the +PHPStan pass being stricter than xphp's own generic checks, not a generics error; +it applies to any covariant getter-over-storage (including the docs' own +`ImmutableList` / `Producer` examples). + +### ✅ Workaround + +- Run the generic checks without the PHPStan pass: `xphp check src --no-phpstan` + (the generics themselves still validate). +- Or scope a PHPStan ignore to the generated getter in your `phpstan.neon` + (e.g. `ignoreErrors` on `#Method .*::get\(\) should return.*but returns mixed#`). +- Or narrow inside the getter body so PHPStan can prove the type, e.g. + `assert($this->item instanceof Fruit); return $this->item;` (only viable when a + concrete bound is known). + +The construction side is unaffected — the constructor parameter keeps its real +type and is runtime-type-checked. + +--- + ## `T[]` is xphp-only ### ❌ What doesn't work diff --git a/docs/syntax/variance.md b/docs/syntax/variance.md index ce60ac6..2e1f9ed 100644 --- a/docs/syntax/variance.md +++ b/docs/syntax/variance.md @@ -143,7 +143,9 @@ anchor, so `final` on a `+T`/`-T` class is rejected at compile time.) > covariance *and* a real construction-time guarantee — nothing is erased. The > one position that can't carry a real `T` is a stored **property** (PHP property > types are invariant across the edge), so hold elements in a plain `array`/`mixed` -> backing field and expose them through a covariant `get(): T`, as above. +> backing field and expose them through a covariant `get(): T`, as above. (That +> `mixed`-backed getter compiles and runs fine, but trips the optional `xphp check` +> PHPStan pass — see [caveats](../caveats.md#covariant-getters-trip-the-xphp-check-phpstan-pass).) ### Inner-template variance composition From c8919efae858953e322009fd375bf3944cfd0a41 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 21 Jun 2026 17:41:43 +0000 Subject: [PATCH 055/114] feat(variance): allow variance markers on private properties A `+T`/`-T` marker on a property was rejected for every visibility. That over-restricts: PHP enforces invariant property types across an `extends` chain only for visible (public/protected) members. A *private* slot is per-declaring-scope and never inherited, so the variance edge `Box extends Box` autoloads with no fatal even when the specializations declare `private Banana $item` / `private Fruit $item`; a private member is also invisible to the externally-visible variance surface. Relax the rule: a private property (declared or promoted; mutable or readonly) is variance-exempt, like a non-promoted constructor parameter, while public/protected properties stay strictly invariant. Detection uses the PRIVATE visibility bit, so a `readonly`-only promoted param (implicitly public) and an asymmetric `public private(set)` property (externally readable) both correctly stay invariant. This lets the natural covariant shape `class Producer<+T> { public function __construct(private T $item) {} ... }` compile, keep its real substituted slot type (nothing erased), and stay runtime-type-checked at construction. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/InnerVarianceValidator.php | 37 ++++++-- src/Transpiler/Monomorphize/Specializer.php | 13 ++- src/Transpiler/Monomorphize/Variance.php | 22 +++-- .../VariancePositionValidator.php | 78 ++++++++++----- .../VarianceEdgeIntegrationTest.php | 51 ++++++++++ .../VariancePositionPhaseTest.php | 95 +++++++++++++++++++ .../source/Banana.xphp | 13 +++ .../source/Box.xphp | 27 ++++++ .../source/Fruit.xphp | 12 +++ .../source/Use.xphp | 16 ++++ .../verify/runtime.php | 54 +++++++++++ 11 files changed, 370 insertions(+), 48 deletions(-) create mode 100644 test/fixture/compile/generic_covariant_private_property/source/Banana.xphp create mode 100644 test/fixture/compile/generic_covariant_private_property/source/Box.xphp create mode 100644 test/fixture/compile/generic_covariant_private_property/source/Fruit.xphp create mode 100644 test/fixture/compile/generic_covariant_private_property/source/Use.xphp create mode 100644 test/fixture/compile/generic_covariant_private_property/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/InnerVarianceValidator.php b/src/Transpiler/Monomorphize/InnerVarianceValidator.php index cebaa51..e5b3bab 100644 --- a/src/Transpiler/Monomorphize/InnerVarianceValidator.php +++ b/src/Transpiler/Monomorphize/InnerVarianceValidator.php @@ -4,6 +4,7 @@ namespace XPHP\Transpiler\Monomorphize; +use PhpParser\Modifiers; use PhpParser\Node; use PhpParser\Node\ComplexType; use PhpParser\Node\Identifier; @@ -111,16 +112,32 @@ private function collect(GenericDefinition $definition): void // below skips promoted ones (they're `Param`, not `Property`), // so each promoted property is walked exactly once. // - // Exception: a non-promoted constructor param typed by a bare - // covariant/contravariant type-param is allowed — a constructor - // parameter isn't part of the externally-visible variance surface - // (constructors aren't called through upcast references), and PHP - // exempts `__construct` from LSP, so the real type is emitted with - // no hazard. Skip it. Inner-generic constructor params (e.g. `Container`) - // are still checked, as are promoted params (they're properties). + // Two exemptions skip a param entirely: + // - A non-promoted constructor param typed by a bare + // covariant/contravariant type-param — a constructor parameter + // isn't part of the externally-visible variance surface + // (constructors aren't called through upcast references), and PHP + // exempts `__construct` from LSP, so the real type is emitted with + // no hazard. + // - A *private* promoted property (handled just below) — PHP doesn't + // type-check private slots across the chain. + // Inner-generic constructor params (e.g. `Container`) and + // visible (public/protected) promoted properties are still checked. if ($isConstructor && $this->isExemptVariantConstructorParam($param)) { continue; } + // A *private* promoted constructor property is exempt: PHP does not + // type-check private property types across an `extends` chain, and a + // private slot is invisible to the variance surface, so it imposes no + // composition constraint regardless of its (possibly inner-generic) + // shape. Detect via the PRIVATE bit — a readonly-only promoted param + // (no visibility bit, implicitly public) is NOT skipped. This is a + // separate skip from `isExemptVariantConstructorParam` on purpose: + // that helper matches only bare single-segment type-params, so it + // would still (wrongly) walk `private Container $x`. + if ($isConstructor && ($param->flags & Modifiers::PRIVATE) !== 0) { + continue; + } // A by-reference parameter is read AND written back, so it's an // invariant outer position regardless of method vs constructor // (e.g. a by-ref of a covariant container `f(Container &$x)`). @@ -136,7 +153,11 @@ private function collect(GenericDefinition $definition): void } } foreach ($definition->templateAst->getProperties() as $prop) { - if ($prop->type !== null) { + // A private property is exempt (PHP doesn't type-check private slots + // across the `extends` chain; invisible to the variance surface), so it + // imposes no inner-variance constraint regardless of shape — only a + // *visible* (public/protected) typed property is walked. + if (!$prop->isPrivate() && $prop->type !== null) { $this->walkPhpType($prop->type, Variance::Invariant, $label, null, null); } } diff --git a/src/Transpiler/Monomorphize/Specializer.php b/src/Transpiler/Monomorphize/Specializer.php index c80c23e..2332a10 100644 --- a/src/Transpiler/Monomorphize/Specializer.php +++ b/src/Transpiler/Monomorphize/Specializer.php @@ -46,11 +46,14 @@ final class Specializer * `__construct` from LSP signature checks, so a `T`-typed constructor parameter * specializes to its real type (`Banana ...$items`) and stays valid across the * variance `extends` chain, giving a real runtime type check at construction. - * A `T`-typed *property* (mutable, readonly, or promoted) is the one shape that - * can't cross the edge — PHP property types are invariant — and is rejected - * upstream by the variance-position validator, not erased here. A `final` - * variant class is likewise rejected upstream (a `final` class can't anchor a - * variance `extends` edge), so no `final` needs stripping here. + * A `T`-typed *public/protected property* (mutable, readonly, or promoted) is the + * one shape that can't cross the edge — PHP enforces invariant property types across + * the chain for visible members — and is rejected upstream by the variance-position + * validator, not erased here. A `T`-typed *private* property DOES cross the edge and + * is substituted to its real type (PHP doesn't type-check private slots across the + * chain; each specialization re-emits its own field + accessor). A `final` variant + * class is likewise rejected upstream (a `final` class can't anchor a variance + * `extends` edge), so no `final` needs stripping here. * * The cloned class's `name` is intentionally NOT set here — SpecializedClassGenerator::emit * is the single source of truth for the final shortname (derived from the generated FQCN). diff --git a/src/Transpiler/Monomorphize/Variance.php b/src/Transpiler/Monomorphize/Variance.php index 998d217..4ede913 100644 --- a/src/Transpiler/Monomorphize/Variance.php +++ b/src/Transpiler/Monomorphize/Variance.php @@ -17,15 +17,19 @@ * a plain constructor parameter). `T1 <: T2` lifts to `Box <: Box` * (flipped). * - * Property positions (mutable AND readonly, including a *promoted* constructor - * parameter), bounds, and defaults are strict-invariant for both `+T` and `-T`: - * PHP enforces invariant property types across `extends` chains regardless of - * `readonly`, so a covariance allowance there would PHP-fatal at autoload when - * the variance edge emits. A **by-reference** parameter (`T &$x`) is also - * invariant — it is read and written back, acting as input and output at once. - * A plain (non-promoted, non-by-ref) constructor parameter, by contrast, may - * carry any variance: a constructor isn't part of the visible variance surface - * and PHP exempts `__construct` from LSP, so its real type is emitted. + * A **public/protected** property position (mutable AND readonly, including a + * public/protected *promoted* constructor parameter), bounds, and defaults are + * strict-invariant for both `+T` and `-T`: PHP enforces invariant property types + * across `extends` chains regardless of `readonly`, so a covariance allowance + * there would PHP-fatal at autoload when the variance edge emits. A **private** + * property (declared or promoted, mutable or readonly), by contrast, may carry + * any variance: PHP does not type-check private slots across the chain and a + * private slot is invisible to the variance surface, so the real substituted + * type is emitted soundly. A **by-reference** parameter (`T &$x`) is invariant — + * it is read and written back, acting as input and output at once. A plain + * (non-promoted, non-by-ref) constructor parameter may also carry any variance: + * a constructor isn't part of the visible variance surface and PHP exempts + * `__construct` from LSP, so its real type is emitted. * * String-backed so the registry JSON serializes cleanly. */ diff --git a/src/Transpiler/Monomorphize/VariancePositionValidator.php b/src/Transpiler/Monomorphize/VariancePositionValidator.php index ce27401..faa9447 100644 --- a/src/Transpiler/Monomorphize/VariancePositionValidator.php +++ b/src/Transpiler/Monomorphize/VariancePositionValidator.php @@ -4,6 +4,7 @@ namespace XPHP\Transpiler\Monomorphize; +use PhpParser\Modifiers; use PhpParser\Node; use PhpParser\Node\ComplexType; use PhpParser\Node\Expr\ArrowFunction; @@ -30,21 +31,32 @@ * * Position rules (PHP-compat surface): * - * - Property type (mutable OR readonly) -> Invariant only - * - Promoted constructor parameter -> Invariant only (it is a property) - * - Non-promoted constructor parameter -> any variance (emitted with real type) - * - Method/function parameter type -> Invariant or Contravariant - * - Method/function return type -> Invariant or Covariant - * - Bound expression -> Invariant only - * - Default expression -> Invariant only + * - Public/protected property (mutable OR readonly) -> Invariant only + * - Private property (mutable OR readonly) -> any variance + * - Promoted constructor parameter (public/protected) -> Invariant only (it is a visible property) + * - Promoted constructor parameter (private) -> any variance (private property) + * - Non-promoted constructor parameter -> any variance (emitted with real type) + * - Method/function parameter type -> Invariant or Contravariant + * - Method/function return type -> Invariant or Covariant + * - Bound expression -> Invariant only + * - Default expression -> Invariant only * - * Why properties are strict-invariant: PHP enforces invariant property types - * across the `extends` chain regardless of `readonly`. A covariant +T in a - * subtype property declaration would PHP-fatal at autoload when the variance - * edge `Producer_Banana extends Producer_Fruit` lands. The semantic - * argument ("readonly = output-only") doesn't override PHP's static-type - * rule. Users who need a covariant getter use a `mixed`-typed (or - * bound-typed) backing field + a method `get(): T`. + * Why public/protected properties are strict-invariant: PHP enforces invariant + * property types across the `extends` chain regardless of `readonly`. A covariant + * +T in a subtype property declaration would PHP-fatal at autoload when the + * variance edge `Producer_Banana extends Producer_Fruit` lands. The semantic + * argument ("readonly = output-only") doesn't override PHP's static-type rule. + * + * Why a PRIVATE property may carry any variance: PHP does NOT type-check private + * property types across an `extends` chain — a private slot is per-declaring-scope + * and is never inherited/overridden, so `Producer_Banana` may declare + * `private Banana $item` while `Producer_Fruit` declares `private Fruit $item` + * with no fatal. It is also invisible to the externally-visible variance surface. + * The Specializer emits the real substituted type there, and each specialization + * re-emits its own field + accessor, so no inherited method ever reads a + * divergent-typed private slot. This is what lets the covariant getter pattern + * `class Producer<+T> { public function __construct(private T $item) {} … }` be + * both real-typed and sound. * * Why a non-promoted constructor parameter may carry any variance: a * constructor isn't part of the externally-visible variance surface (it's @@ -52,8 +64,8 @@ * constructor parameters), and PHP exempts `__construct` from LSP signature * checks, so each specialization's constructor may legitimately differ. The * Specializer emits the real substituted type there -- nothing is erased. A - * *promoted* constructor parameter is a property, so it falls under the - * strict-invariant property rule above. + * *promoted* constructor parameter is a property, so it falls under the property + * rules above: strict-invariant when public/protected, any variance when private. * * F-bounded variance (`class Sortable<+T : Comparable>`) is rejected * because `+T` appears inside its own bound (an invariant position). @@ -208,8 +220,15 @@ private function checkProperty(Property $property): void if ($type === null) { return; } - // PHP enforces invariant property types across `extends` chains - // regardless of `readonly`. Even +T on a readonly property would + // A private property is exempt: PHP does not type-check private property + // types across an `extends` chain (the slot is per-declaring-scope, never + // inherited), and it is invisible to the variance surface. So a private + // `T` property may carry any variance — like a non-promoted ctor param. + if ($property->isPrivate()) { + return; + } + // Public/protected: PHP enforces invariant property types across `extends` + // chains regardless of `readonly`. Even +T on a readonly property would // PHP-fatal at autoload when the variance edge lands. $position = $property->isReadonly() ? 'readonly property' : 'mutable property'; $this->checkPhpType($type, [Variance::Invariant], $position); @@ -227,13 +246,15 @@ private function checkMethod(ClassMethod $method): void : [Variance::Invariant, Variance::Contravariant]; $paramPosition = $isConstructor ? 'constructor parameter' : 'method parameter'; // A variant class (≥1 covariant/contravariant type-param) may carry its - // type-param in a NON-promoted constructor parameter at any variance: a - // constructor parameter is not part of the externally-visible variance - // surface (you can't call a constructor through an upcast reference), and - // PHP exempts `__construct` from LSP, so the specializer emits the real - // substituted type there with no soundness or autoload hazard. A *promoted* - // constructor param is also a property, which stays strictly invariant (a `T`-typed - // property would PHP-fatal across the chain regardless). + // type-param in a constructor parameter at any variance UNLESS the param is + // a *visible* (public/protected) promoted property. A constructor parameter + // is not part of the externally-visible variance surface (you can't call a + // constructor through an upcast reference), and PHP exempts `__construct` + // from LSP, so the specializer emits the real substituted type there with no + // soundness or autoload hazard. A *promoted* constructor param is also a + // property: a public/protected one stays strictly invariant (it would + // PHP-fatal across the chain), but a *private* promoted property is exempt + // (PHP doesn't type-check private slots across the chain). $classIsVariant = $this->varianceByName !== []; foreach ($method->params as $param) { // @phpstan-ignore-next-line instanceof.alwaysTrue — defensive guard against nikic/php-parser PHPDoc-narrowed param collection element. @@ -253,7 +274,12 @@ private function checkMethod(ClassMethod $method): void continue; } $isPromoted = $param->flags !== 0; - $allowed = ($isConstructor && !$isPromoted && $classIsVariant) + // A *visible* promoted property is public or protected: it carries a + // visibility bit other than PRIVATE. Detect via the PRIVATE bit, not the + // mere absence of a bit — a `readonly`-only promoted param has flags with + // no visibility bit and is implicitly public, so it must stay invariant. + $isVisibleProperty = $isPromoted && ($param->flags & Modifiers::PRIVATE) === 0; + $allowed = ($isConstructor && !$isVisibleProperty && $classIsVariant) ? [Variance::Invariant, Variance::Covariant, Variance::Contravariant] : $paramAllowed; $this->checkPhpType($param->type, $allowed, $paramPosition); diff --git a/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php b/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php index a2f6b6e..7ff1672 100644 --- a/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php +++ b/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php @@ -90,6 +90,57 @@ public function testCovariantImmutableCollectionTakesTypedConstructorInput(): vo } } + #[RunInSeparateProcess] + public function testCovariantPrivatePropertyStoresRealTypeAndIsRuntimeChecked(): void + { + // A covariant container `Box<+T>` that stores its element in a PRIVATE + // promoted property of type `T`. Each specialization keeps the REAL slot + // type (`private Banana $item` / `private Fruit $item`), the variance edge + // `Box extends Box` autoloads with NO fatal (PHP doesn't + // type-check private property types across the chain), a Banana box is + // usable where a Fruit box is expected, and construction is runtime-checked. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_covariant_private_property/source', + 'variance-covariant-private-property', + ); + try { + $specializationDir = $fixture->cacheDir . '/Generated/App/CovariantPrivateProperty/Box'; + $files = glob($specializationDir . '/T_*.php') ?: []; + self::assertCount(2, $files, 'two Box specializations (Fruit, Banana)'); + + $combined = ''; + $extendsEdges = 0; + foreach ($files as $file) { + $content = file_get_contents($file); + self::assertIsString($content); + $combined .= $content; + if (str_contains($content, 'extends \\XPHP\\Generated\\App\\CovariantPrivateProperty\\Box\\T_')) { + $extendsEdges++; + } + } + // Each specialization keeps its REAL private slot type — nothing erased. + self::assertSame( + 1, + preg_match_all('/private \\\\App\\\\CovariantPrivateProperty\\\\Fruit \$item/', $combined), + 'Fruit specialization stores `private Fruit $item`', + ); + self::assertSame( + 1, + preg_match_all('/private \\\\App\\\\CovariantPrivateProperty\\\\Banana \$item/', $combined), + 'Banana specialization stores `private Banana $item`', + ); + self::assertStringNotContainsString('private mixed $item', $combined, 'nothing is erased to mixed'); + // Exactly one specialization extends the other — the covariant edge. + self::assertSame(1, $extendsEdges, 'Box extends Box'); + self::assertStringNotContainsString('final class', $combined); + + $fixture->registerAutoload('App\\CovariantPrivateProperty'); + require __DIR__ . '/../../fixture/compile/generic_covariant_private_property/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + public function testBoundedCovariantConstructorKeepsConcreteType(): void { // A bounded covariant constructor param keeps its REAL substituted type (the diff --git a/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php b/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php index 8ce3814..8375839 100644 --- a/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php +++ b/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php @@ -54,6 +54,31 @@ public static function rejectedSources(): iterable "\n{\n public function __construct(public T \$item) {}\n}\n", ['constructor parameter'], ]; + // A *protected* property/promoted property is also a visible property (PHP + // enforces invariant types across the chain for it), so it stays rejected — + // only PRIVATE is exempt. These pin the PRIVATE-bit detection against a + // mutant that swaps the visibility bit for PROTECTED. + yield 'covariant in protected promoted constructor property' => [ + "\n{\n public function __construct(protected T \$item) {}\n}\n", + ['constructor parameter'], + ]; + yield 'covariant in protected mutable property' => [ + "\n{\n protected T \$item;\n}\n", + ['mutable property'], + ]; + // Asymmetric visibility (PHP 8.4): a `public private(set)` property is + // externally *readable* through an upcast reference (PRIVATE_SET sets a + // separate bit, not PRIVATE), so it's on the visible variance surface and + // stays strictly invariant. Only a truly *private* slot is exempt. These + // pin the PRIVATE-bit detection against a mutant that swaps it for PRIVATE_SET. + yield 'covariant in public private(set) promoted constructor property' => [ + "\n{\n public function __construct(public private(set) T \$item) {}\n}\n", + ['constructor parameter'], + ]; + yield 'covariant in public private(set) declared property' => [ + "\n{\n public private(set) T \$item;\n}\n", + ['mutable property'], + ]; yield 'covariant in nested closure parameter' => [ "\n{\n public function emit(): array\n {\n \$f = function (T \$x) {};\n return [];\n }\n}\n", ['nested closure/arrow parameter'], @@ -96,6 +121,42 @@ public static function rejectedSources(): iterable ]; } + /** + * Sources that must pass variance-position validation. A PRIVATE property + * (declared or promoted; mutable or readonly; bare or inner-generic) is exempt + * from the property-invariance rule: PHP doesn't type-check private slots across + * the `extends` chain, and a private slot is invisible to the variance surface. + * + * @return iterable + */ + public static function allowedSources(): iterable + { + yield 'covariant in private promoted constructor property' => [ + "\n{\n public function __construct(private T \$item) {}\n public function get(): T { return \$this->item; }\n}\n", + ]; + yield 'covariant in private declared property' => [ + "\n{\n private T \$item;\n public function get(): T { return \$this->item; }\n}\n", + ]; + yield 'covariant in private readonly declared property' => [ + "\n{\n private readonly T \$item;\n public function get(): T { return \$this->item; }\n}\n", + ]; + yield 'covariant in private readonly promoted property' => [ + "\n{\n public function __construct(private readonly T \$item) {}\n public function get(): T { return \$this->item; }\n}\n", + ]; + yield 'contravariant in private promoted constructor property' => [ + "\n{\n public function __construct(private T \$item) {}\n public function accept(T \$x): void {}\n}\n", + ]; + // Inner-generic private members are exempt too — the inner-variance walk + // skips them, so `private Container` doesn't trip composition even though + // a *visible* `Container` property would (Container's slot is invariant). + yield 'covariant in private inner-generic declared property' => [ + "\n{\n private Box \$item;\n public function get(): T { throw new \\LogicException; }\n}\n", + ]; + yield 'covariant in private inner-generic promoted property' => [ + "\n{\n public function __construct(private Box \$item) {}\n public function get(): T { throw new \\LogicException; }\n}\n", + ]; + } + /** * @param list $fragments */ @@ -114,6 +175,40 @@ public function testVariancePositionIsRejectedInCompileMode(string $source, arra } } + /** + * A private property carrying a variance marker must pass BOTH the position + * check and the inner-variance composition check (the latter for the + * inner-generic cases). Neither phase may throw. + */ + #[DataProvider('allowedSources')] + public function testPrivatePropertyVarianceIsAllowed(string $source): void + { + $registry = $this->registryFor($source); + + $registry->validateVariancePositions(); // must NOT throw + $registry->validateInnerVariance(); // must NOT throw + + self::assertTrue(true); + } + + public function testPrivatePromotedDoesNotShortCircuitLaterConstructorParam(): void + { + // A private promoted constructor property is skipped in the inner-variance + // walk — but the skip must `continue`, not `break`: a LATER constructor + // param that DOES violate (an inner-generic `Box`, T through an invariant + // slot) must still be reached and rejected. The position phase passes both + // params (a bare/inner-generic ctor param is position-allowed in a variant + // class), so inner-variance is the phase that must catch the trailing one. + $source = "\n{\n public function __construct(private T \$first, Box \$second) {}\n}\n"; + $registry = $this->registryFor($source); + + $registry->validateVariancePositions(); // must NOT throw — both params position-allowed + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Variance violation'); + $registry->validateInnerVariance(); + } + public function testViolationIsCollectedWithMemberLineInCheckMode(): void { // Line 5 holds `public function set(T $x)`. diff --git a/test/fixture/compile/generic_covariant_private_property/source/Banana.xphp b/test/fixture/compile/generic_covariant_private_property/source/Banana.xphp new file mode 100644 index 0000000..c08dfd7 --- /dev/null +++ b/test/fixture/compile/generic_covariant_private_property/source/Banana.xphp @@ -0,0 +1,13 @@ +` shape — and it is sound: +// PHP does NOT type-check private property types across an `extends` chain (a +// private slot is per-declaring-scope, never inherited), so the variance edge +// `Box extends Box` lands with NO autoload fatal even though the +// specializations declare `private Banana $item` and `private Fruit $item`. +// The constructor keeps its REAL element type on each specialization (PHP +// exempts `__construct` from LSP), so construction is runtime-type-checked. +// Not `final` — a variant class can't be (its specializations are linked by +// `extends` edges). +class Box<+T> +{ + public function __construct(private T $item) + { + } + + public function get(): T + { + return $this->item; + } +} diff --git a/test/fixture/compile/generic_covariant_private_property/source/Fruit.xphp b/test/fixture/compile/generic_covariant_private_property/source/Fruit.xphp new file mode 100644 index 0000000..b0a7dd5 --- /dev/null +++ b/test/fixture/compile/generic_covariant_private_property/source/Fruit.xphp @@ -0,0 +1,12 @@ + is accepted where a Box is expected, because +// Banana extends Fruit and T is covariant — the element is read back through a +// covariant `get(): T`. +function nameOf(Box $box): string +{ + return $box->get()->name; +} + +$bananaBox = new Box::(new Banana()); +$name = nameOf($bananaBox); diff --git a/test/fixture/compile/generic_covariant_private_property/verify/runtime.php b/test/fixture/compile/generic_covariant_private_property/verify/runtime.php new file mode 100644 index 0000000..21f0903 --- /dev/null +++ b/test/fixture/compile/generic_covariant_private_property/verify/runtime.php @@ -0,0 +1,54 @@ +` that stores its element in a PRIVATE promoted + * property of type `T`. The variance edge `Box extends Box` + * autoloads with no PHP fatal even though each specialization declares a + * divergent-typed private slot (PHP doesn't type-check private property types + * across the chain), a `Box` is usable where a `Box` is expected + * (covariant `get(): T`), and the constructor keeps its REAL element type so + * construction is runtime-type-checked. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use App\CovariantPrivateProperty\Banana; +use App\CovariantPrivateProperty\Fruit; +use PHPUnit\Framework\Assert; +use XPHP\Transpiler\Monomorphize\Registry; +use XPHP\Transpiler\Monomorphize\TypeRef; + +require $fixture->targetDir . '/Use.php'; + +// Covariant use worked: a Box flowed into a Box parameter and the +// element read back through `get(): T`. +Assert::assertSame('banana', $name); + +$bananaBoxFqn = Registry::generatedFqn( + 'App\\CovariantPrivateProperty\\Box', + [new TypeRef('App\\CovariantPrivateProperty\\Banana')], +); +$fruitBoxFqn = Registry::generatedFqn( + 'App\\CovariantPrivateProperty\\Box', + [new TypeRef('App\\CovariantPrivateProperty\\Fruit')], +); + +// The private slot type is REAL (not erased to `mixed`) and runtime-checked at +// construction: a `Box` rejects a plain `Fruit`. +$threw = false; +try { + new $bananaBoxFqn(new Fruit('apple')); +} catch (\TypeError) { + $threw = true; +} +Assert::assertTrue($threw, 'Box must reject a non-Banana element at construction'); + +// And it accepts a real Banana, exposing it through the covariant getter. +$ok = new $bananaBoxFqn(new Banana()); +Assert::assertSame('banana', $ok->get()->name); + +// The covariant edge is real: a Box IS a Box at the type level. +Assert::assertInstanceOf($fruitBoxFqn, $ok); From a506e50dc2b21381a5fadcfd478fd93b69d5c629 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 21 Jun 2026 17:54:26 +0000 Subject: [PATCH 056/114] docs(variance): document private-property variance + PHPStan-clean test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the docs in line with the relaxed rule (a private property may carry `+T`/`-T`): - variance.md: revert the covariant `Producer<+T>` example to the natural `__construct(private T $item)` + `get(): T` shape (no `mixed` backing); split the position table property rows by visibility; rewrite the prose and the emitted-output snippet; rework the collections note to scope the `mixed`/`array` backing to multi-element collections. - caveats.md: narrow the "covariant getter trips the PHPStan pass" caveat to the `array`-backed collection case — a single-value `private T` getter is now PHPStan-clean. - roadmap.md, errors.md: position-rule summaries note the private exemption. - ADR-0015: record the decision (private = exempt because PHP doesn't type-check private slots across the edge; detection by the private bit, so `readonly`-only-promoted and `public private(set)` stay invariant); ADR-0013 cross-references it and drops its stale "all properties invariant" claims. - CHANGELOG: add the entry; fix the stale promoted-param sentence. Add a grouped PHPStan test proving the private-`T` covariant getter produces no PHPStan diagnostics (same level-5 config under which the analogous `returns mixed` error is caught, so a clean result is a real proof). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 19 ++- ...nstructor-parameters-on-variant-classes.md | 33 +++-- ...-variance-markers-on-private-properties.md | 132 ++++++++++++++++++ docs/adr/README.md | 1 + docs/caveats.md | 49 ++++--- docs/errors.md | 7 +- docs/roadmap.md | 7 +- docs/syntax/variance.md | 62 +++++--- test/Console/CheckCommandPhpStanTest.php | 33 +++++ 9 files changed, 279 insertions(+), 64 deletions(-) create mode 100644 docs/adr/0015-variance-markers-on-private-properties.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 510cd63..4078fe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,9 +30,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 specialisation (`Book ...$items`, not `mixed`), so construction is **runtime-type-checked** while `ImmutableList` still extends `ImmutableList` — PHP exempts `__construct` from LSP, so the - specialisations' constructors may differ across the edge. Promoted constructor - params remain properties (strictly invariant — PHP property types can't vary - across the edge). See [variance](docs/syntax/variance.md). + specialisations' constructors may differ across the edge. A public/protected + promoted constructor param remains a visible property (strictly invariant — PHP + enforces property types across the edge for visible members), but a *private* + one is exempt (see below). See [variance](docs/syntax/variance.md). +- **Variance markers on private properties.** A `+T` / `-T` marker is now allowed + on a **private** property — declared or promoted, mutable or readonly — so the + natural covariant shape + `class Producer<+T> { public function __construct(private T $item) {} ... }` + compiles, keeps its **real** substituted slot type (nothing erased), and stays + runtime-type-checked. PHP does not type-check private property types across an + `extends` chain (a private slot is per-declaring-scope and never inherited) and + a private member is invisible to the variance surface, so it carries any variance + soundly. Public/protected properties (including public/protected promoted params, + and an externally-readable `public private(set)` property) stay strictly + invariant. A covariant single-value getter over a `private T` field is also + PHPStan-clean. See [variance](docs/syntax/variance.md). - **`Hashable` value-equality bound.** `Set` and `Map` are now expressible and compile-time bound-checked (referenced fully-qualified, like `\Stringable`). xphp ships no runtime diff --git a/docs/adr/0013-typed-constructor-parameters-on-variant-classes.md b/docs/adr/0013-typed-constructor-parameters-on-variant-classes.md index 03a605c..6ab9202 100644 --- a/docs/adr/0013-typed-constructor-parameters-on-variant-classes.md +++ b/docs/adr/0013-typed-constructor-parameters-on-variant-classes.md @@ -54,13 +54,15 @@ variant class cannot be `final`. Rather than silently strip `final` from the gen specializations (which would make `ReflectionClass::isFinal()` lie about them), xphp **rejects** `final` on a `+T`/`-T` class at compile time. -The relaxation is narrow. **Properties stay strictly invariant** — mutable, `readonly`, -and *promoted* constructor parameters (which are properties). PHP makes property types -invariant across an `extends` chain (`Type of Child::$item must be …`), so a `T`-typed +The relaxation is narrow. **Visible (public/protected) properties stay strictly +invariant** — mutable, `readonly`, and public/protected *promoted* constructor parameters +(which are visible properties). PHP makes property types invariant across an `extends` +chain *for visible members* (`Type of Child::$item must be …`), so a `T`-typed visible property genuinely fatals — there is no way to carry a real `T` there. Such a property is -rejected at compile time (not erased); store elements in a plain `array`/`mixed` backing -field and expose them through a covariant `get(): T`. Non-bare shapes in a constructor -parameter (`?T`, `Box`, `T|X`) are not yet supported and stay rejected. +rejected at compile time (not erased). A **private** property is the exception — PHP does +not type-check private slots across the chain, so it carries a real `T` soundly; see +[ADR-0015](0015-variance-markers-on-private-properties.md). Non-bare shapes in a +constructor parameter (`?T`, `Box`, `T|X`) are not yet supported and stay rejected. ### Consequences @@ -68,18 +70,21 @@ parameter (`?T`, `Box`, `T|X`) are not yet supported and stay rejected. enforced at runtime, remains usable covariantly (`ImmutableList` where `ImmutableList` is expected), and never fatals at autoload. - Good: nothing is erased — the declared type survives into the emitted signature. -- Trade-off: a `T`-typed property (mutable/`readonly`/promoted) is still rejected, because - PHP property invariance across the edge is unavoidable. Users hand-roll a `mixed`/`array` - backing field plus a covariant `get(): T`. +- Trade-off: a `T`-typed *visible* (public/protected) property is rejected, because PHP + property invariance across the edge is unavoidable for visible members. A `T`-typed + *private* property is allowed (see [ADR-0015](0015-variance-markers-on-private-properties.md)); + a *multi-element* collection still hand-rolls a `mixed`/`array` backing plus a covariant + `get(): T`, since many elements can't live in one `private T` slot. - Trade-off: richer constructor-parameter shapes (`?T`, `Box`, `T|X`) aren't supported yet, only a bare variance-marked type parameter. ### Confirmation `VariancePositionValidator::checkMethod` allows a plain constructor parameter of a variant -class at any variance (a promoted one stays invariant); `InnerVarianceValidator` skips a -bare variance-marked constructor parameter (`isExemptVariantConstructorParam`) and still rejects -the non-bare shapes. [`Specializer::specialize`](../../src/Transpiler/Monomorphize/Specializer.php) +class at any variance (a public/protected promoted one stays invariant; a private promoted +one is exempt — see [ADR-0015](0015-variance-markers-on-private-properties.md)); +`InnerVarianceValidator` skips a bare variance-marked constructor parameter +(`isExemptVariantConstructorParam`) and still rejects the non-bare shapes. [`Specializer::specialize`](../../src/Transpiler/Monomorphize/Specializer.php) substitutes the real type into the constructor parameter — no erasure step. Tests compile a covariant `ImmutableList<+T>` and assert each specialization's constructor keeps its real element type, that the chain autoloads with **no** fatal, that an `ImmutableList` @@ -92,7 +97,9 @@ autoloads and constructs equally cleanly. - Good: keeps the real type and a runtime check; no autoload fatal; localized to the specializer (just normal substitution). -- Bad: properties still can't carry a real `T`; non-bare constructor shapes not yet supported. +- Bad: visible (public/protected) properties still can't carry a real `T` (a *private* one + can — [ADR-0015](0015-variance-markers-on-private-properties.md)); non-bare constructor + shapes not yet supported. ### Variance-erased constructor parameter diff --git a/docs/adr/0015-variance-markers-on-private-properties.md b/docs/adr/0015-variance-markers-on-private-properties.md new file mode 100644 index 0000000..f15a45a --- /dev/null +++ b/docs/adr/0015-variance-markers-on-private-properties.md @@ -0,0 +1,132 @@ +# 15. Variance markers on private properties + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +Declaration-site variance (`+T` / `-T`) lowers to real `extends` edges between +specializations: `Producer` extends `Producer` when `Banana` extends `Fruit` +and `T` is covariant ([ADR-0001](0001-monomorphization-over-type-erasure.md)). A variant +class therefore can't carry its type parameter in a position PHP would reject across that +edge. + +The original rule treated **every** property as such a position: a `T`-typed property — +mutable, `readonly`, or promoted — was rejected at compile time, justified by "PHP enforces +invariant property types across an `extends` chain." So the natural covariant shape + +```php +class Producer<+T> { + public function __construct(private T $item) {} + public function get(): T { return $this->item; } +} +``` + +was rejected, and authors had to hand-roll a `mixed` backing field plus a covariant +`get(): T` — which also trips the optional PHPStan-over-output pass +([ADR-0009](0009-phpstan-over-compiled-output.md)), since a `mixed` field reads as `mixed`. + +The premise turned out to be too broad. PHP enforces invariant property types across the +chain **only for visible (public/protected) members**. A *private* property is not +inherited or overridden — its slot is per-declaring-scope — so PHP does **not** type-check +it across the edge. The question: should a private property be exempt from the +property-invariance rule, like a non-promoted constructor parameter already is +([ADR-0013](0013-typed-constructor-parameters-on-variant-classes.md))? + +## Decision Drivers + +- Soundness first — an allowed position must not produce an autoload fatal or a wrong-typed + read at runtime. +- Don't over-restrict — rejecting a position PHP actually permits forces users into + workarounds (a `mixed` backing field) that are both noisier and less type-safe. +- Keep the variance surface honest — only positions invisible to the externally-visible + variance surface may differ across the edge. + +## Considered Options + +- **Keep rejecting all properties** — simplest rule, but over-restrictive: it bans a sound, + natural covariant shape and forces a `mixed`-backed workaround that defeats PHPStan. +- **Allow any property (drop the invariance rule)** — unsound: a public/protected `T` + property genuinely fatals at autoload when the variance edge lands. +- **Allow only `private` properties** — exempt a private property (declared or promoted; + mutable or readonly), keep public/protected strictly invariant. + +## Decision Outcome + +Chosen: **a `private` property is variance-exempt — it may carry any variance — while +public/protected properties stay strictly invariant.** A private property is treated like a +non-promoted constructor parameter: it is invisible to the externally-visible variance +surface (it can't be read through an upcast reference), and PHP doesn't type-check its slot +across the edge, so each specialization keeps its own real-typed field (`private Banana +$item` / `private Fruit $item`) with no fatal. The Specializer emits the real substituted +type there — nothing is erased. + +Soundness holds because monomorphization is total: each specialization re-emits its **own** +private field and its own accessor body, so no inherited parent method ever reads a +divergent-typed private slot of a child instance — the classic hazard is structurally +impossible. + +Detection is by the **`private` visibility bit**, not the mere absence of a bit. A +`readonly`-only promoted parameter has no visibility bit and is implicitly public; an +asymmetric `public private(set)` property (PHP 8.4) is externally readable. Both are on the +visible variance surface and correctly stay strictly invariant — only a truly private slot +is exempt. + +This refines, and does not supersede, [ADR-0013](0013-typed-constructor-parameters-on-variant-classes.md): +typed constructor *parameters* remain real-typed and LSP-exempt; this ADR adds that a +private promoted (or declared) *property* is likewise exempt. + +### Consequences + +- Good: the natural covariant single-value shape (`__construct(private T $item)` + `get(): + T`) compiles, keeps its real slot type (runtime-type-checked at construction), and is + **PHPStan-clean** — no `mixed` backing, so the getter's return type is provable. +- Good: the rule now matches what PHP actually enforces — no position is rejected that PHP + would have accepted. +- Trade-off: a *multi-element* collection still needs a `mixed`/`array` backing (many + elements can't live in one `private T` slot), so its covariant `get(): T` still trips the + optional PHPStan pass. That case is documented, not "fixed." +- Trade-off: a public/protected `T` property is still rejected — unavoidable, PHP fatals on + it across the edge. + +### Confirmation + +The exemption lives in two validators — the property and promoted-parameter checks in +[`VariancePositionValidator`](../../src/Transpiler/Monomorphize/VariancePositionValidator.php) +and the inner-variance composition walk in +[`InnerVarianceValidator`](../../src/Transpiler/Monomorphize/InnerVarianceValidator.php) — +both keyed on the `private` visibility bit. It is pinned by tests: private declared/promoted +(mutable, `readonly`, inner-generic) properties compile; public/protected and the +externally-readable `public private(set)` shape stay rejected. A runtime-verify fixture +proves the covariant edge autoloads, a `Box` flows where a `Box` is expected, +and construction throws `TypeError` on a wrong element; a grouped test proves the private-`T` +getter is PHPStan-clean. The boundary is documented in [Variance](../syntax/variance.md) and +[Caveats](../caveats.md). + +## Pros and Cons of the Options + +### Allow only `private` properties (chosen) + +- Good: sound (PHP doesn't check private slots across the edge), matches PHP's real rule, + unlocks the natural covariant shape, PHPStan-clean for single-value containers. +- Bad: multi-element collections still need a `mixed`/`array` backing. + +### Keep rejecting all properties + +- Good: simplest rule. +- Bad: over-restrictive; forces a `mixed`-backed workaround that is noisier and defeats the + PHPStan pass even for the single-value case PHP permits. + +### Allow any property + +- Good: maximal expressiveness. +- Bad: unsound — a public/protected `T` property fatals at autoload when the edge lands. + +## More Information + +- [ADR-0001](0001-monomorphization-over-type-erasure.md) — specializations and `extends` + edges. +- [ADR-0013](0013-typed-constructor-parameters-on-variant-classes.md) — typed constructor + parameters; this ADR refines its property-invariance corollary. +- [ADR-0009](0009-phpstan-over-compiled-output.md) — the PHPStan pass the `mixed` backing + tripped. +- [Variance](../syntax/variance.md), [Caveats](../caveats.md). diff --git a/docs/adr/README.md b/docs/adr/README.md index 2bae3ff..2af0730 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -32,3 +32,4 @@ should be added here as a new numbered file; copy | [0012](0012-engineering-quality-bar.md) | The engineering quality bar | Accepted | | [0013](0013-typed-constructor-parameters-on-variant-classes.md) | Typed constructor parameters on variant classes | Accepted | | [0014](0014-variance-markers-are-class-level-only.md) | Variance markers are class-level only | Accepted | +| [0015](0015-variance-markers-on-private-properties.md) | Variance markers on private properties | Accepted | diff --git a/docs/caveats.md b/docs/caveats.md index 6c01a14..e02e840 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -366,42 +366,49 @@ can see it. --- -## Covariant getters trip the `xphp check` PHPStan pass +## Covariant getters over an `array` backing trip the `xphp check` PHPStan pass + +> **Scope:** this affects only a *multi-element* covariant collection backed by an +> `array` field. A covariant **single-value** container no longer hits this — store +> the element in a `private T` property (PHP doesn't type-check private slots across +> the `extends` edge), and the emitted `get(): T` over a real-typed `private T` field +> is PHPStan-clean. See the [`Producer<+T>`](syntax/variance.md#example) example. ### ❌ What gets flagged ```php -class Producer<+T> { - private mixed $item; // backing field can't be `T` (properties are invariant) - public function __construct(T $item) { $this->item = $item; } - public function get(): T { return $this->item; } +class ImmutableList<+T> { + private array $items; // many elements → `array` backing, not `T` + public function __construct(T ...$items) { $this->items = $items; } + public function get(int $i): T { return $this->items[$i]; } } -$p = new Producer::(new Banana()); // compiles + runs fine +$list = new ImmutableList::(new Banana()); // compiles + runs fine ``` `xphp compile` is happy and the runtime is correct, but `xphp check`'s optional [PHPStan-over-the-compiled-output pass](errors.md#phpstan-over-the-compiled-output) -reports, on the `Producer` specialization: +reports, on the `ImmutableList` specialization: ``` -Method ...\Producer\T_::get() should return App\Banana but returns mixed. +Method ...\ImmutableList\T_::get() should return App\Banana but returns mixed. [phpstan.return.type] ``` ### Why -A covariant container can't store its element in a `T`-typed **property** (PHP -property types are invariant across the `extends` edge — see -[variance markers are class-level only](#variance-markers-are-class-level-only) -above and the [variance](syntax/variance.md) rules), so the backing field is -`mixed`/`array`. xphp substitutes type parameters in **signatures** (the emitted -`get(): Banana` is correct), but **not inside method bodies** — `return -$this->item` still reads a `mixed` field. PHPStan, analysing the concrete output, -sees `mixed` returned where `Banana` is declared and reports it. This is the -PHPStan pass being stricter than xphp's own generic checks, not a generics error; -it applies to any covariant getter-over-storage (including the docs' own -`ImmutableList` / `Producer` examples). +A collection holds *many* elements in a single field, so the backing must be an +`array` (you can't fit them in one `private T` slot). xphp substitutes type +parameters in **signatures** (the emitted `get(): Banana` is correct), but **not +inside method bodies** — `return $this->items[$i]` reads an element of an `array`, +which PHPStan sees as `mixed`. Analysing the concrete output, it reports `mixed` +returned where `Banana` is declared. This is the PHPStan pass being stricter than +xphp's own generic checks, not a generics error. + +A single-value container avoids this entirely because its backing field can be a +real-typed `private T` (a private property is variance-exempt — see the +[variance](syntax/variance.md) rules), so PHPStan proves the getter's return type +directly. The limitation is specific to the `array`-backed collection shape. ### ✅ Workaround @@ -410,8 +417,8 @@ it applies to any covariant getter-over-storage (including the docs' own - Or scope a PHPStan ignore to the generated getter in your `phpstan.neon` (e.g. `ignoreErrors` on `#Method .*::get\(\) should return.*but returns mixed#`). - Or narrow inside the getter body so PHPStan can prove the type, e.g. - `assert($this->item instanceof Fruit); return $this->item;` (only viable when a - concrete bound is known). + `assert($el instanceof Fruit); return $el;` (only viable when a concrete bound + is known). The construction side is unaffected — the constructor parameter keeps its real type and is runtime-type-checked. diff --git a/docs/errors.md b/docs/errors.md index 46ec022..589e5b1 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -167,8 +167,11 @@ allowed for variance. `` is the forbidding position — e.g. `method parameter`, `method return`, `mutable property`, `readonly property`, or -`by-reference parameter`. A **by-reference parameter** (`T &$x`) is invariant -(it is read and written back), so neither `+T` nor `-T` is allowed there. +`by-reference parameter`. A property only forbids variance when it is +**public or protected**; a *private* property (declared or promoted) is exempt, +because PHP doesn't type-check private slots across the `extends` chain. A +**by-reference parameter** (`T &$x`) is invariant (it is read and written back), +so neither `+T` nor `-T` is allowed there. ### Variant class declared `final` diff --git a/docs/roadmap.md b/docs/roadmap.md index e7e884d..1bd6f35 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -162,9 +162,10 @@ upcoming one. - `+T` and `-T` markers on type parameters. - Position rules enforced at compile time (covariant in return, contravariant in param, any variance in a plain non-promoted - constructor parameter; both forbidden in properties — including - promoted constructor params — by-reference params, bounds, and - defaults). + constructor parameter or a *private* property — declared or promoted; + both forbidden in public/protected properties — including + public/protected promoted constructor params — by-reference params, + bounds, and defaults). - Real-typed construction: a `+T`/`-T` constructor parameter keeps its concrete type (nothing erased), so construction is runtime-type-checked. - A `final` variant class is rejected (a `final` class can't anchor the diff --git a/docs/syntax/variance.md b/docs/syntax/variance.md index 2e1f9ed..f7f2a4b 100644 --- a/docs/syntax/variance.md +++ b/docs/syntax/variance.md @@ -14,12 +14,12 @@ declare(strict_types=1); namespace App; -// Covariant: T appears in return positions (and a plain constructor parameter). -// The backing field is `mixed`, not `T` — a `T`-typed *property* is invariant -// and would be rejected (see the rules below). +// Covariant: T appears in return positions (and in a private property). +// A *private* `T` property is variance-exempt — PHP doesn't type-check private +// slots across the `extends` chain — so the backing field keeps its real type +// (a public/protected `T` property would be rejected; see the rules below). class Producer<+T> { - private mixed $item; - public function __construct(T $item) { $this->item = $item; } + public function __construct(private T $item) {} public function get(): T { return $this->item; } } @@ -54,12 +54,12 @@ specializations: namespace XPHP\Generated\App\Producer; class T_ implements \App\Producer { - public function __construct(\App\Fruit $item) { ... } // real type, runtime-checked + public function __construct(private \App\Fruit $item) { ... } // real type, runtime-checked public function get(): \App\Fruit { ... } } class T_ extends T_ implements \App\Producer { - public function __construct(\App\Banana $item) { ... } // narrowed; `__construct` is LSP-exempt + public function __construct(private \App\Banana $item) { ... } // narrowed; `__construct` is LSP-exempt and the private slot isn't checked across the edge public function get(): \App\Banana { ... } } ``` @@ -81,17 +81,29 @@ Position rules, enforced at compile time over the collected definitions | Method parameter | ❌ | ✅ | | By-reference parameter (`T &$x`) | ❌ | ❌ | | Constructor parameter (plain) | ✅ | ✅ | -| Mutable property | ❌ | ❌ | -| Readonly property | ❌ | ❌ | -| Promoted constructor property | ❌ | ❌ | +| Private property (mutable/readonly) | ✅ | ✅ | +| Private promoted constructor property | ✅ | ✅ | +| Public/protected property | ❌ | ❌ | +| Public/protected promoted property | ❌ | ❌ | | Bound expression | ❌ | ❌ | | Default expression | ❌ | ❌ | -The strict-invariance rule on **properties** (mutable, readonly, and -promoted-constructor) is forced by the runtime model: xphp emits real -`extends` chains between specialised classes, and PHP enforces invariant -property types across those chains regardless of `readonly` — a covariant -property would PHP-fatal at autoload when the variance edge lands. +The strict-invariance rule on **public/protected properties** (mutable, +readonly, and promoted-constructor) is forced by the runtime model: xphp emits +real `extends` chains between specialised classes, and PHP enforces invariant +property types across those chains for visible members regardless of `readonly` +— a covariant one would PHP-fatal at autoload when the variance edge lands. + +A **private property** (declared or promoted, mutable or readonly) is the +exception: PHP does **not** type-check private property types across an `extends` +chain — a private slot is per-declaring-scope and is never inherited — so each +specialisation keeps its own real-typed field (`private Banana $item` / +`private Fruit $item`) with no fatal. A private member is also invisible to the +externally-visible variance surface, so it may carry any variance soundly, and +xphp emits it with its **real** substituted type. (Detection is by the `private` +visibility bit: a `readonly`-only promoted parameter is implicitly public, and an +asymmetric `public private(set)` property is externally readable, so both stay +strictly invariant.) A **by-reference parameter** (`function f(T &$x)`) is likewise invariant: the caller's variable is both read and written back through the reference, so it acts @@ -106,8 +118,10 @@ the same reason Kotlin exempts constructor parameters from variance checks), and PHP exempts `__construct` from LSP signature checks, so the specialisations' constructors may legitimately differ across the edge. That's what lets a covariant immutable collection take *type-checked* construction input (see -below). A *promoted* constructor parameter is a property, so it stays strictly -invariant. +below). A *promoted* constructor parameter is a property, so it follows the +property rules above: a public/protected one stays strictly invariant, while a +**private** one is exempt and keeps its real type — which is exactly what makes +the covariant single-value `Producer<+T>` shape at the top of this page work. ### Covariant immutable collections (typed construction) @@ -141,11 +155,15 @@ anchor, so `final` on a `+T`/`-T` class is rejected at compile time.) > keeps its real type, PHP enforces it at construction: building an > `ImmutableList` from a non-`Book` throws a `TypeError`. You get both > covariance *and* a real construction-time guarantee — nothing is erased. The -> one position that can't carry a real `T` is a stored **property** (PHP property -> types are invariant across the edge), so hold elements in a plain `array`/`mixed` -> backing field and expose them through a covariant `get(): T`, as above. (That -> `mixed`-backed getter compiles and runs fine, but trips the optional `xphp check` -> PHPStan pass — see [caveats](../caveats.md#covariant-getters-trip-the-xphp-check-phpstan-pass).) +> one property shape that can't carry a real `T` is a **non-private** (public or +> protected) stored property — PHP enforces invariant property types across the +> edge for visible members. A **private** stored property *can* hold a real `T` +> (see the single-value `Producer<+T>` at the top), so a covariant single-value +> container needs no `mixed` backing at all. A *multi-element* collection like +> `ImmutableList` is different: many elements live in one `private array $items` +> field, and `array` is `mixed` to PHPStan — so the covariant `get(): T` over an +> `array` backing compiles and runs fine but trips the optional `xphp check` +> PHPStan pass (see [caveats](../caveats.md#covariant-getters-over-an-array-backing-trip-the-xphp-check-phpstan-pass)). ### Inner-template variance composition diff --git a/test/Console/CheckCommandPhpStanTest.php b/test/Console/CheckCommandPhpStanTest.php index 3ad84db..9bac291 100644 --- a/test/Console/CheckCommandPhpStanTest.php +++ b/test/Console/CheckCommandPhpStanTest.php @@ -68,6 +68,33 @@ public function testReportsBodyTypeErrorAtTheTemplateDeclaration(): void self::assertStringStartsWith('phpstan.', $boxError[0]['code']); } + public function testCovariantPrivatePropertyGetterIsPhpStanClean(): void + { + // A covariant single-value container that stores its element in a real-typed + // `private T` property emits a `get(): Banana` over a `private Banana $item` + // field — which PHPStan can prove. Unlike an `array`-backed collection (whose + // getter returns `mixed` and trips the pass), this shape is fully clean. Run + // with the same level-5 config that catches the analogous `returns mixed` + // error, so a clean result is a real proof, not a too-lax level. + $tester = $this->tester(); + $exit = $tester->execute([ + 'source' => $this->privatePropertyFixtureDir(), + '--phpstan-bin' => $this->bin, + '--phpstan-config' => $this->level5Config(), + '--format' => 'json', + ]); + + /** @var array{diagnostics: list} $decoded */ + $decoded = json_decode($tester->getDisplay(), true, flags: JSON_THROW_ON_ERROR); + $phpstan = array_values(array_filter( + $decoded['diagnostics'], + static fn (array $d): bool => $d['source'] === 'phpstan', + )); + + self::assertSame([], $phpstan, 'private-T covariant getter must produce no PHPStan diagnostics'); + self::assertSame(0, $exit); + } + public function testNoPhpstanFlagSkipsThePassAndStaysClean(): void { $tester = $this->tester(); @@ -139,6 +166,12 @@ private function fixtureDir(string $fixture): string ?: throw new RuntimeException("Fixture missing: {$fixture}"); } + private function privatePropertyFixtureDir(): string + { + return realpath(__DIR__ . '/../fixture/compile/generic_covariant_private_property/source') + ?: throw new RuntimeException('private-property fixture missing'); + } + private function level5Config(): string { return realpath(__DIR__ . '/../fixture/check/phpstan-level5.neon') From 7d1ee6bca435c9363c712f279228b44abd877f7b Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 21 Jun 2026 18:10:22 +0000 Subject: [PATCH 057/114] docs(variance): correct the array-backed PHPStan caveat to match real output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empirically, the `array`-backed covariant collection trips the `xphp check` PHPStan pass with `missingType.iterableValue` ("Property ...::$items type has no value type specified in iterable type array") at level 6+, not the `return.type` "should return X but returns mixed" error — that message belongs to the obsolete single-`mixed`-field shape and only appears at level 9. The single-value `private T` shape is clean at every level. Fix the caveat (heading, flagged error, why, workarounds, level threshold), the variance.md closing note + anchor link, and the ADR-0015 trade-off lines to state the actual error and that it is specific to the untyped `array` property the specializer emits. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-variance-markers-on-private-properties.md | 10 ++-- docs/caveats.md | 53 +++++++++++-------- docs/syntax/variance.md | 7 +-- 3 files changed, 40 insertions(+), 30 deletions(-) diff --git a/docs/adr/0015-variance-markers-on-private-properties.md b/docs/adr/0015-variance-markers-on-private-properties.md index f15a45a..e3c7d3c 100644 --- a/docs/adr/0015-variance-markers-on-private-properties.md +++ b/docs/adr/0015-variance-markers-on-private-properties.md @@ -82,9 +82,10 @@ private promoted (or declared) *property* is likewise exempt. **PHPStan-clean** — no `mixed` backing, so the getter's return type is provable. - Good: the rule now matches what PHP actually enforces — no position is rejected that PHP would have accepted. -- Trade-off: a *multi-element* collection still needs a `mixed`/`array` backing (many - elements can't live in one `private T` slot), so its covariant `get(): T` still trips the - optional PHPStan pass. That case is documented, not "fixed." +- Trade-off: a *multi-element* collection still needs an `array` backing (many elements + can't live in one `private T` slot), which xphp emits without a value-type annotation, so + it still trips the optional PHPStan pass at level 6+ (the untyped `array` property has no + iterable value type). That case is documented, not "fixed." - Trade-off: a public/protected `T` property is still rejected — unavoidable, PHP fatals on it across the edge. @@ -108,7 +109,8 @@ getter is PHPStan-clean. The boundary is documented in [Variance](../syntax/vari - Good: sound (PHP doesn't check private slots across the edge), matches PHP's real rule, unlocks the natural covariant shape, PHPStan-clean for single-value containers. -- Bad: multi-element collections still need a `mixed`/`array` backing. +- Bad: multi-element collections still need an untyped `array` backing (trips the PHPStan + pass at level 6+). ### Keep rejecting all properties diff --git a/docs/caveats.md b/docs/caveats.md index e02e840..5ea6ca9 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -366,13 +366,14 @@ can see it. --- -## Covariant getters over an `array` backing trip the `xphp check` PHPStan pass +## Covariant `array`-backed collections trip the `xphp check` PHPStan pass > **Scope:** this affects only a *multi-element* covariant collection backed by an -> `array` field. A covariant **single-value** container no longer hits this — store -> the element in a `private T` property (PHP doesn't type-check private slots across -> the `extends` edge), and the emitted `get(): T` over a real-typed `private T` field -> is PHPStan-clean. See the [`Producer<+T>`](syntax/variance.md#example) example. +> `array` field, at **PHPStan level 6 and above**. A covariant **single-value** +> container no longer hits this — store the element in a `private T` property (PHP +> doesn't type-check private slots across the `extends` edge), and the emitted +> `get(): T` over a real-typed `private T` field is PHPStan-clean at every level. +> See the [`Producer<+T>`](syntax/variance.md#example) example. ### ❌ What gets flagged @@ -387,38 +388,44 @@ $list = new ImmutableList::(new Banana()); // compiles + runs fine ``` `xphp compile` is happy and the runtime is correct, but `xphp check`'s -optional [PHPStan-over-the-compiled-output pass](errors.md#phpstan-over-the-compiled-output) -reports, on the `ImmutableList` specialization: +optional [PHPStan-over-the-compiled-output pass](errors.md#phpstan-over-the-compiled-output), +**at level 6 or higher**, reports on the `ImmutableList` specialization: ``` -Method ...\ImmutableList\T_::get() should return App\Banana but returns mixed. -[phpstan.return.type] +Property ...\ImmutableList\T_::$items type has no value type specified +in iterable type array. +[missingType.iterableValue] ``` +(At level ≤5 it is clean — the missing-iterable-value-type rule only switches on at +level 6.) + ### Why -A collection holds *many* elements in a single field, so the backing must be an -`array` (you can't fit them in one `private T` slot). xphp substitutes type -parameters in **signatures** (the emitted `get(): Banana` is correct), but **not -inside method bodies** — `return $this->items[$i]` reads an element of an `array`, -which PHPStan sees as `mixed`. Analysing the concrete output, it reports `mixed` -returned where `Banana` is declared. This is the PHPStan pass being stricter than -xphp's own generic checks, not a generics error. +A collection holds *many* elements in one field, so the backing must be an `array` +(you can't fit them in a single `private T` slot). xphp substitutes type parameters +in **signatures** (the emitted `get(): Banana` is correct) but emits the backing as +a plain `private array $items` with **no value-type annotation** — PHP has no native +typed array, and xphp doesn't synthesise a `@var Banana[]` docblock for the +specialization. From level 6 PHPStan requires a value type on every iterable, so it +flags the untyped `array` property. This is the PHPStan pass being stricter than +xphp's own generic checks, not a generics error. (The element read +`return $this->items[$i]` is `mixed`, but PHPStan reports the *property*'s missing +value type rather than the return.) A single-value container avoids this entirely because its backing field can be a real-typed `private T` (a private property is variance-exempt — see the -[variance](syntax/variance.md) rules), so PHPStan proves the getter's return type -directly. The limitation is specific to the `array`-backed collection shape. +[variance](syntax/variance.md) rules), so there is no untyped `array` at all. The +limitation is specific to the `array`-backed collection shape. ### ✅ Workaround - Run the generic checks without the PHPStan pass: `xphp check src --no-phpstan` (the generics themselves still validate). -- Or scope a PHPStan ignore to the generated getter in your `phpstan.neon` - (e.g. `ignoreErrors` on `#Method .*::get\(\) should return.*but returns mixed#`). -- Or narrow inside the getter body so PHPStan can prove the type, e.g. - `assert($el instanceof Fruit); return $el;` (only viable when a concrete bound - is known). +- Or analyse at level ≤5 (the missing-iterable-value-type rule is off there). +- Or scope a PHPStan ignore to the generated property in your `phpstan.neon` + (e.g. `ignoreErrors` on + `#Property .*::\$items type has no value type specified in iterable type array#`). The construction side is unaffected — the constructor parameter keeps its real type and is runtime-type-checked. diff --git a/docs/syntax/variance.md b/docs/syntax/variance.md index f7f2a4b..c8d10e5 100644 --- a/docs/syntax/variance.md +++ b/docs/syntax/variance.md @@ -161,9 +161,10 @@ anchor, so `final` on a `+T`/`-T` class is rejected at compile time.) > (see the single-value `Producer<+T>` at the top), so a covariant single-value > container needs no `mixed` backing at all. A *multi-element* collection like > `ImmutableList` is different: many elements live in one `private array $items` -> field, and `array` is `mixed` to PHPStan — so the covariant `get(): T` over an -> `array` backing compiles and runs fine but trips the optional `xphp check` -> PHPStan pass (see [caveats](../caveats.md#covariant-getters-over-an-array-backing-trip-the-xphp-check-phpstan-pass)). +> field, which xphp emits without a value-type annotation — so it compiles and +> runs fine but trips the optional `xphp check` PHPStan pass at level 6+ (the +> untyped `array` property has no iterable value type; see +> [caveats](../caveats.md#covariant-array-backed-collections-trip-the-xphp-check-phpstan-pass)). ### Inner-template variance composition From d4150eb248610ca02bd4a7422c41809d983803de Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 21 Jun 2026 18:15:18 +0000 Subject: [PATCH 058/114] docs(roadmap): list the shipped `Hashable` value-equality bound The Bounds section omitted the `Hashable` bound (`Set` / `Map`), already recognized in the compiler's built-in type whitelist and covered in the changelog and caveats. Add it. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/roadmap.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/roadmap.md b/docs/roadmap.md index 1bd6f35..c707bb2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -146,6 +146,10 @@ upcoming one. - DNF bound (e.g. `T : (A & B) | C`). - F-bounded recursion (e.g. `T : Comparable`). - Built-in interface whitelist (Stringable / Countable / Iterator / ...). +- `Hashable` value-equality bound — `Set` / + `Map` are expressible and compile-time bound-checked. + Not a PHP-native interface; xphp ships no runtime `Hashable`, so you (or + your collection library) provide the `hashCode()` / `equals()` contract. - Error messages reference the source-level instantiation, not the generated hash. From 0c989ca58e26e267397b41031a0b3bc98e09226a Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 21 Jun 2026 18:48:50 +0000 Subject: [PATCH 059/114] feat(bounds): namespace the value-equality bound as `XPHP\Hashable` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recognized value-equality bound was the global `Hashable` — the only non-PHP-native entry in the built-in-type whitelist, and a generic global name that could collide with a future PHP-native `\Hashable` (bounds are nominally erased, so xphp would silently conflate them). Rename it to a namespaced `XPHP\Hashable`, referenced fully-qualified like `\Stringable`; xphp still ships no runtime interface (the consumer provides it). The name-resolution special-case that lets a built-in resolve unqualified is scoped to single-segment natives, so a project's own bare `Hashable` is no longer shadowed. Still unreleased, so no compatibility cost. Records the rationale as ADR-0016; updates the tests, caveats, roadmap, and changelog. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 12 ++- docs/adr/0016-namespaced-hashable-bound.md | 94 +++++++++++++++++++ docs/adr/README.md | 1 + docs/caveats.md | 18 ++-- docs/roadmap.md | 9 +- src/Transpiler/Monomorphize/TypeHierarchy.php | 21 +++-- .../BoundedGenericIntegrationTest.php | 14 +-- .../Monomorphize/TypeHierarchyTest.php | 28 ++++-- 8 files changed, 159 insertions(+), 38 deletions(-) create mode 100644 docs/adr/0016-namespaced-hashable-bound.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4078fe7..e951013 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,11 +46,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and an externally-readable `public private(set)` property) stay strictly invariant. A covariant single-value getter over a `private T` field is also PHPStan-clean. See [variance](docs/syntax/variance.md). -- **`Hashable` value-equality bound.** `Set` and - `Map` are now expressible and compile-time bound-checked - (referenced fully-qualified, like `\Stringable`). xphp ships no runtime - `Hashable`; you or your collection library provide the contract - (`hashCode(): int|string`, `equals(self): bool`). See [caveats](docs/caveats.md). +- **`XPHP\Hashable` value-equality bound.** `Set` and + `Map` are now expressible and compile-time bound-checked + (referenced fully-qualified, like `\Stringable`). The name is deliberately + namespaced — not a global `\Hashable` — so it can never collide with a future + PHP-native interface. xphp ships no runtime `XPHP\Hashable`; you or your + collection library provide the contract (`hashCode(): int|string`, + `equals(self): bool`). See [caveats](docs/caveats.md). ### Fixed diff --git a/docs/adr/0016-namespaced-hashable-bound.md b/docs/adr/0016-namespaced-hashable-bound.md new file mode 100644 index 0000000..6ad8207 --- /dev/null +++ b/docs/adr/0016-namespaced-hashable-bound.md @@ -0,0 +1,94 @@ +# 16. A namespaced `XPHP\Hashable` value-equality bound + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +PHP array keys are `int|string` only, so a generic container that keys on or +deduplicates **arbitrary objects** needs a value-equality contract — a +`hashCode()` / `equals()` pair — to bound its key parameter on. xphp recognizes +such a bound so `Set` / `Map` are expressible and +compile-time bound-checked, without xphp shipping any runtime interface (bounds +are nominally erased — [ADR-0005](0005-nominal-erased-bound-checking.md)). + +The recognized name lives in the compiler's built-in-type whitelist +(`TypeHierarchy::BUILTIN_TYPES`), alongside the real PHP-native interfaces +(`Stringable`, `Countable`, …) that user code can implement without xphp seeing +their declaration. The question is **what to call it**. It was originally the +global `Hashable`. Every other whitelist entry is a real PHP global interface that +PHP itself guarantees at that name; `Hashable` is the only invented one, and a +global `\Hashable` is a generic, collision-prone name. + +## Decision Drivers + +- A name xphp invents must not squat a global identifier PHP (or a widely-used + library) might later define with a *different* contract — bounds are erased, so + xphp would silently conflate the two and enforce nothing. +- Keep xphp a pure transpiler: it should ship no runtime interface; the consumer + (or their collection library) provides the contract. +- Stay vendor-neutral — don't hard-code one specific library's interface as the + only recognized value-equality bound. + +## Considered Options + +- **Global `\Hashable`** (the original) — simplest, but squats a generic global + name and is the lone non-native entry in a whitelist of real PHP globals. +- **`Ds\Hashable`** (the established ext-ds / php-ds interface) — namespaced and + real, but hard-codes one library's interface as the blessed bound. +- **A namespaced, vendor-neutral `XPHP\Hashable`** — recognized under xphp's own + namespace, provided by the consumer. + +## Decision Outcome + +Chosen: **recognize a namespaced `XPHP\Hashable`**, dropping the magic from the +global `Hashable`. It is referenced fully-qualified (`\XPHP\Hashable`) or via a +`use`, exactly like the built-in `\Stringable` bound, and the consumer provides +the interface (xphp still ships nothing): + +```php +namespace XPHP; + +interface Hashable { + public function hashCode(): int|string; + public function equals(self $other): bool; +} +``` + +Namespacing makes a collision with a future PHP-native global interface +structurally impossible — PHP reserves the global namespace, not `XPHP\`. The name +stays vendor-neutral (it doesn't bless php-ds or any other library), while keeping +xphp a pure transpiler: the `XPHP\Hashable` name is recognized, but no runtime type +is emitted or required from xphp itself. + +The change is free of compatibility cost: the bound was still unreleased when it was +renamed, so no published code depended on the global name. + +### Consequences + +- Good: no global-namespace squat; the recognized bound can never clash with a + future `\Hashable` in PHP core or a popular library. +- Good: bare `Hashable` is no longer magic — inside any namespace it follows normal + PHP name resolution, so a project's own `App\Hashable` is never shadowed. +- Trade-off: the contract lives under xphp's namespace, so a consumer who wants the + bound defines (or aliases) an interface at `XPHP\Hashable`. Acceptable — it's a + one-line interface, and the namespace clearly signals "the bound xphp recognizes." +- Trade-off: not auto-aligned with `Ds\Hashable`; a php-ds user aliases or + re-declares rather than reusing `Ds\Hashable` directly. + +### Confirmation + +The recognized name is the single entry `XPHP\Hashable` in +[`TypeHierarchy::BUILTIN_TYPES`](../../src/Transpiler/Monomorphize/TypeHierarchy.php); +the name-resolution special-case that lets a built-in resolve unqualified is scoped +to single-segment (no-namespace) natives, so a relative `XPHP\Hashable` follows +normal PHP namespacing. Tests pin that `Set` compiles against a +class that `implements \XPHP\Hashable`, that a non-implementing class is rejected +with the bound named in the error, and that a bare global `\Hashable` is no longer a +recognized type. Documented in [Caveats](../caveats.md) and the +[roadmap](../roadmap.md). + +## More Information + +- [ADR-0005](0005-nominal-erased-bound-checking.md) — nominal, erased bound checking + (why xphp recognizes a bound name without enforcing its method shape). +- [Caveats](../caveats.md) — object-keyed containers and the `XPHP\Hashable` bound. diff --git a/docs/adr/README.md b/docs/adr/README.md index 2af0730..4692882 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -33,3 +33,4 @@ should be added here as a new numbered file; copy | [0013](0013-typed-constructor-parameters-on-variant-classes.md) | Typed constructor parameters on variant classes | Accepted | | [0014](0014-variance-markers-are-class-level-only.md) | Variance markers are class-level only | Accepted | | [0015](0015-variance-markers-on-private-properties.md) | Variance markers on private properties | Accepted | +| [0016](0016-namespaced-hashable-bound.md) | A namespaced `XPHP\Hashable` value-equality bound | Accepted | diff --git a/docs/caveats.md b/docs/caveats.md index 5ea6ca9..9e30a18 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -466,30 +466,34 @@ class Map { PHP array keys are `int|string` only, so `$this->items[$k] = $v` works only when `K` is a string/int — it **fatals for object keys**. For a container keyed on (or deduplicating) arbitrary objects, bound the type -parameter on `\Hashable`, the recognized value-equality bound, and key on +parameter on `\XPHP\Hashable`, the recognized value-equality bound, and key on `hashCode()` internally: ```php -class Map { +class Map { private array $buckets = []; public function set(K $k, V $v): void { $this->buckets[$k->hashCode()] = [$k, $v]; } public function get(K $k): V { return $this->buckets[$k->hashCode()][1]; } } ``` -xphp **recognizes** the `\Hashable` bound (so `Map` and -`Set` compile and are bound-checked) but ships **no** runtime -`Hashable` interface — it's a pure transpiler. You (or your collection -library) provide the contract, e.g.: +xphp **recognizes** the `\XPHP\Hashable` bound (so `Map` +and `Set` compile and are bound-checked) but ships **no** +runtime `XPHP\Hashable` interface — it's a pure transpiler. The name is +deliberately **namespaced** (not a global `\Hashable`) so it can never collide +with a future PHP-native interface. You (or your collection library) provide the +contract, e.g.: ```php +namespace XPHP; + interface Hashable { public function hashCode(): int|string; public function equals(self $other): bool; } ``` -Reference it fully-qualified (`\Hashable`) or via `use`, the same as the +Reference it fully-qualified (`\XPHP\Hashable`) or via `use`, the same as the built-in `\Stringable` bound. The deduping/keying logic itself is ordinary runtime code in your container — the bound just gives it a type-checked contract. diff --git a/docs/roadmap.md b/docs/roadmap.md index c707bb2..0e5911a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -146,10 +146,11 @@ upcoming one. - DNF bound (e.g. `T : (A & B) | C`). - F-bounded recursion (e.g. `T : Comparable`). - Built-in interface whitelist (Stringable / Countable / Iterator / ...). -- `Hashable` value-equality bound — `Set` / - `Map` are expressible and compile-time bound-checked. - Not a PHP-native interface; xphp ships no runtime `Hashable`, so you (or - your collection library) provide the `hashCode()` / `equals()` contract. +- `XPHP\Hashable` value-equality bound — `Set` / + `Map` are expressible and compile-time bound-checked. + Namespaced (not a global `\Hashable`) to avoid colliding with a future + PHP-native interface; xphp ships no runtime `XPHP\Hashable`, so you (or your + collection library) provide the `hashCode()` / `equals()` contract. - Error messages reference the source-level instantiation, not the generated hash. diff --git a/src/Transpiler/Monomorphize/TypeHierarchy.php b/src/Transpiler/Monomorphize/TypeHierarchy.php index 7c5b9ca..c161c3c 100644 --- a/src/Transpiler/Monomorphize/TypeHierarchy.php +++ b/src/Transpiler/Monomorphize/TypeHierarchy.php @@ -54,12 +54,14 @@ 'Error', 'BackedEnum', 'UnitEnum', - // Not a PHP-native interface: `Hashable` is the recognized value-equality - // bound so `Set` / `Map` are - // expressible and compile-time-checked. xphp ships no runtime `Hashable` - // — the consumer (or their collection library) provides the interface + // Not a PHP-native interface: `XPHP\Hashable` is the recognized value-equality + // bound so `Set` / `Map` are + // expressible and compile-time-checked. It is deliberately *namespaced* (not a + // global `\Hashable`) so it can never collide with a future PHP-native global + // interface — every other entry here is a real PHP global, this one is not. + // xphp ships no runtime `XPHP\Hashable` — the consumer provides the interface // contract (`hashCode(): int|string`, `equals(self): bool`). - 'Hashable', + 'XPHP\\Hashable', ]; /** @@ -253,9 +255,12 @@ private function resolveName(Name $name): string $rest = substr($raw, strlen($first)); return $this->useMap[$first] . $rest; } - // Special case: built-in interfaces have no namespace; if the raw name matches a - // known built-in we resolve as-is rather than appending the current namespace. - if (in_array($raw, TypeHierarchy::BUILTIN_TYPES, true)) { + // Special case: the PHP-native built-in interfaces have no namespace, so an + // unqualified reference to one resolves as-is rather than getting the current + // namespace appended. This is scoped to single-segment names — a namespaced + // built-in like `XPHP\Hashable` is only matched fully-qualified (handled above) + // or via a `use`, never relative, so it follows normal PHP namespacing here. + if (strpos($raw, '\\') === false && in_array($raw, TypeHierarchy::BUILTIN_TYPES, true)) { return $raw; } return $this->qualify($raw); diff --git a/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php b/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php index 35d4223..7b62df5 100644 --- a/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php +++ b/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php @@ -59,17 +59,17 @@ public function testBoundIsSatisfiedByImplementingClass(): void public function testHashableBoundIsSatisfiedByImplementingClass(): void { - // `Hashable` is a whitelisted bound name. xphp recognizes it + // `XPHP\Hashable` is a whitelisted bound name. xphp recognizes it // even though the interface is provided by the consumer/library and isn't - // in the scanned source set here — so `Set` resolves against a - // class that `implements Hashable`. + // in the scanned source set here — so `Set` resolves + // against a class that `implements \XPHP\Hashable`. $sourceDir = $this->workDir . '/src'; mkdir($sourceDir, 0o755, true); $setFile = $sourceDir . '/Set.xphp'; file_put_contents($setFile, <<<'PHP' + class Set { private array $items = []; public function add(T $x): void { $this->items[] = $x; } @@ -79,7 +79,7 @@ public function add(T $x): void { $this->items[] = $x; } file_put_contents($userFile, <<<'PHP' + class Set { public function add(T $x): void {} } @@ -131,7 +131,7 @@ class Plain {} $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Generic bound violated'); - $this->expectExceptionMessage('Hashable'); + $this->expectExceptionMessage('XPHP\\Hashable'); $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); } diff --git a/test/Transpiler/Monomorphize/TypeHierarchyTest.php b/test/Transpiler/Monomorphize/TypeHierarchyTest.php index c746cb5..76a56c0 100644 --- a/test/Transpiler/Monomorphize/TypeHierarchyTest.php +++ b/test/Transpiler/Monomorphize/TypeHierarchyTest.php @@ -267,16 +267,30 @@ public function testAncestorChainNormalizesLeadingBackslash(): void public function testHashableIsAWhitelistedBoundName(): void { - // `Hashable` is a recognized bound name even though it isn't a PHP built-in - // and isn't declared in the source set — a class implementing - // it satisfies the bound; a known class that doesn't is rejected. + // `XPHP\Hashable` is a recognized bound name even though it isn't a PHP + // built-in and isn't declared in the source set — a class implementing + // it satisfies the bound; a known class that doesn't is rejected. It is + // namespaced (not a global `\Hashable`) to avoid ever colliding with a + // future PHP-native interface. $hierarchy = new TypeHierarchy([ - 'App\\User' => ['Hashable'], + 'App\\User' => ['XPHP\\Hashable'], 'App\\Plain' => [], ]); - self::assertTrue($hierarchy->isDeclared('Hashable')); - self::assertTrue($hierarchy->isSubtype('App\\User', 'Hashable')); - self::assertFalse($hierarchy->isSubtype('App\\Plain', 'Hashable')); + self::assertTrue($hierarchy->isDeclared('XPHP\\Hashable')); + self::assertTrue($hierarchy->isSubtype('App\\User', 'XPHP\\Hashable')); + self::assertFalse($hierarchy->isSubtype('App\\Plain', 'XPHP\\Hashable')); + } + + public function testBareGlobalHashableIsNotMagic(): void + { + // A bare global `\Hashable` is no longer special — only the namespaced + // `XPHP\Hashable` is the recognized value-equality bound, so an unknown + // bare `Hashable` is not a known type. + $hierarchy = new TypeHierarchy([ + 'App\\Plain' => [], + ]); + + self::assertFalse($hierarchy->isDeclared('Hashable')); } } From d257a497f3700d1ffb7a68cfd439dacfd6eadf67 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 21 Jun 2026 19:56:01 +0000 Subject: [PATCH 060/114] refactor(bounds): drop the special-cased value-equality bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Hashable` was the only invented, non-PHP-native entry in the bounds whitelist (`TypeHierarchy::BUILTIN_TYPES`). Its analog, `Comparable`, is not special-cased — a library declares it and xphp's F-bounded generics handle `Sortable>` end to end. The whitelist also only satisfied the static bound check (an implementing class still needs a real interface at runtime) and could not express the generic `Hashable` form. Remove the special-case entirely. Value-equality, like ordering, is an ordinary library-defined generic interface — `interface Hashable { hashCode(): int|string; equals(T $other): bool; }` bounded via `Set>`. This works with no transpiler change: a generic interface lowers to an empty marker, so an implementer declares `equals(Money $other)` with its concrete type under no LSP obligation, exactly like a `Comparable` implementer's `compareTo`. The capability is covered by the existing bounds_f_bounded fixture; collections remain a separate concern. Removes the whitelist entry and reverts the name-resolution guard; deletes the whitelist-only tests; rewrites the object-keyed-map caveat to the library-defined pattern; records the reversal as ADR-0017 (superseding ADR-0016). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 -- docs/adr/0016-namespaced-hashable-bound.md | 2 +- ...7-no-special-cased-value-equality-bound.md | 101 ++++++++++++++++++ docs/adr/README.md | 3 +- docs/caveats.md | 49 ++++----- docs/roadmap.md | 5 - src/Transpiler/Monomorphize/TypeHierarchy.php | 17 +-- .../BoundedGenericIntegrationTest.php | 78 -------------- .../Monomorphize/TypeHierarchyTest.php | 28 ----- 9 files changed, 132 insertions(+), 158 deletions(-) create mode 100644 docs/adr/0017-no-special-cased-value-equality-bound.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e951013..71c02b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,13 +46,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and an externally-readable `public private(set)` property) stay strictly invariant. A covariant single-value getter over a `private T` field is also PHPStan-clean. See [variance](docs/syntax/variance.md). -- **`XPHP\Hashable` value-equality bound.** `Set` and - `Map` are now expressible and compile-time bound-checked - (referenced fully-qualified, like `\Stringable`). The name is deliberately - namespaced — not a global `\Hashable` — so it can never collide with a future - PHP-native interface. xphp ships no runtime `XPHP\Hashable`; you or your - collection library provide the contract (`hashCode(): int|string`, - `equals(self): bool`). See [caveats](docs/caveats.md). ### Fixed diff --git a/docs/adr/0016-namespaced-hashable-bound.md b/docs/adr/0016-namespaced-hashable-bound.md index 6ad8207..5f6ccd2 100644 --- a/docs/adr/0016-namespaced-hashable-bound.md +++ b/docs/adr/0016-namespaced-hashable-bound.md @@ -1,6 +1,6 @@ # 16. A namespaced `XPHP\Hashable` value-equality bound -- Status: Accepted — 2026-06 +- Status: Superseded by [ADR-0017](0017-no-special-cased-value-equality-bound.md) — 2026-06 ## Context and Problem Statement diff --git a/docs/adr/0017-no-special-cased-value-equality-bound.md b/docs/adr/0017-no-special-cased-value-equality-bound.md new file mode 100644 index 0000000..c289842 --- /dev/null +++ b/docs/adr/0017-no-special-cased-value-equality-bound.md @@ -0,0 +1,101 @@ +# 17. No special-cased value-equality bound — use ordinary generics + +- Status: Accepted — 2026-06 +- Supersedes: [ADR-0016](0016-namespaced-hashable-bound.md) + +## Context and Problem Statement + +A generic container that keys on or deduplicates **arbitrary objects** needs a value-equality +contract (a `hashCode()` / `equals()` pair), because PHP array keys are `int|string` only. xphp +recognized such a contract by **whitelisting** a `Hashable` name in +`TypeHierarchy::BUILTIN_TYPES` — first as the global `Hashable`, then (ADR-0016) as the +namespaced `XPHP\Hashable` — so a bound like `Set` would compile and be +bound-checked without the interface being in the scanned sources, and without xphp shipping a +runtime type. + +This left `Hashable` as the **only invented, non-PHP-native** entry in a whitelist otherwise made +of real PHP global interfaces (`Stringable`, `Countable`, …). Its direct analog — `Comparable`, +an ordering contract — is **not** whitelisted at all: a library declares `interface Comparable` +itself, and xphp's existing F-bounded generics handle `Sortable>` end-to-end +(`test/fixture/compile/bounds_f_bounded/`). The asymmetry had no principled basis. + +Two further facts undercut the whitelist: + +- It only satisfies the **static** bound check. At runtime a class still `implements` the + contract, so a real interface must exist regardless — the whitelist only spared xphp from + *seeing* it during the check. +- It can't express the **generic** `Hashable` form. A whitelisted name is a nominal leaf, but + the natural, type-safe shape is a generic interface whose `equals(T $other)` specializes to the + implementer's concrete type. That requires a real template, not a whitelist entry. + +Collections themselves (Set/Map) are a **separate** project; xphp is a transpiler whose job is to +make generics work, not to carry a domain-specific contract. + +## Decision Drivers + +- Consistency — value-equality should be expressed like every other contract (e.g. ordering), + not via a privileged name. +- Keep the transpiler free of domain types — contracts belong to the libraries built on xphp. +- Prefer the shape that gives natural, type-safe ergonomics over a static-only convenience. + +## Considered Options + +- **Keep the whitelisted `XPHP\Hashable`** (ADR-0016) — zero-setup static recognition, but a + privileged invented name, static-only, and no generic form. +- **Ship a runtime `XPHP\Hashable` interface** — makes the name real, but fixes one contract shape + for everyone and reverses xphp's pure-transpiler stance. +- **Special-case nothing; value-equality is an ordinary library generic** — a library declares + `interface Hashable { hashCode(): int|string; equals(T $other): bool; }` and bounds on + `Set>`, exactly like `Comparable`. + +## Decision Outcome + +Chosen: **the transpiler special-cases nothing.** The `Hashable` whitelist entry is removed. +Value-equality, like ordering, is a contract a library defines as an ordinary generic interface +and bounds on with an F-bound: + +```php +interface Hashable { public function hashCode(): int|string; public function equals(T $other): bool; } +final class Money implements Hashable { /* hashCode(); equals(Money $o): bool */ } +class Set> { /* keys on $k->hashCode() */ } +``` + +This already works with no transpiler change. A generic interface lowers to an **empty marker** +interface (ADR-0004), so the implementing class declares `equals(Money $other)` with its concrete +type under no LSP obligation — identical to how a `Comparable` implementer writes +`compareTo(Money $other)`. The bound is checked nominally and erased (ADR-0005). + +This supersedes ADR-0016: rather than make a privileged name collision-safe, we drop the privilege +entirely. The capability the original ticket asked for — a compile-time-checked value-equality +bound — remains fully available, now uniform with the rest of the bound surface. + +### Consequences + +- Good: one consistent way to express any contract bound; no invented global/namespaced name to + collide with PHP or libraries; the transpiler carries no domain types. +- Good: the generic form gives implementers natural, concrete-typed `equals(T)` with no LSP + friction (empty marker), which the whitelist could never express. +- Trade-off: a library/app now declares its own value-equality interface (a few lines) and keeps + it in its compile set — the exact, trivial cost `Comparable` already pays. Bounding on the + name without declaring it is no longer possible. +- Trade-off: this is a static, compile-time contract only (bounds emit no runtime code); the + deduping/keying container is hand-written library code, as before. + +### Confirmation + +`TypeHierarchy::BUILTIN_TYPES` no longer contains any value-equality entry, and the +`resolveName` special-case is back to matching only the real PHP-native (no-namespace) built-ins. +The library-defined F-bounded-generic pattern is exercised by the existing +`test/fixture/compile/bounds_f_bounded/` (`Comparable` + `Sortable>` + a +class implementing the bare marker with a concrete same-type method); the transpiler is +indifferent to whether that method returns `int` (`compareTo`) or `bool` (`equals`), since the +marker is empty. Documented in [Caveats](../caveats.md); ordering/value-equality both fall under +[type bounds](../syntax/type-bounds.md) F-bounded recursion. + +## More Information + +- [ADR-0016](0016-namespaced-hashable-bound.md) — the superseded namespaced-whitelist decision. +- [ADR-0004](0004-marker-interfaces-for-instanceof.md) — generic templates lower to empty marker + interfaces (why a concrete `equals(T)` has no LSP obligation). +- [ADR-0005](0005-nominal-erased-bound-checking.md) — nominal, erased bound checking. +- [Caveats](../caveats.md), [Type bounds](../syntax/type-bounds.md). diff --git a/docs/adr/README.md b/docs/adr/README.md index 4692882..9976f5a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -33,4 +33,5 @@ should be added here as a new numbered file; copy | [0013](0013-typed-constructor-parameters-on-variant-classes.md) | Typed constructor parameters on variant classes | Accepted | | [0014](0014-variance-markers-are-class-level-only.md) | Variance markers are class-level only | Accepted | | [0015](0015-variance-markers-on-private-properties.md) | Variance markers on private properties | Accepted | -| [0016](0016-namespaced-hashable-bound.md) | A namespaced `XPHP\Hashable` value-equality bound | Accepted | +| [0016](0016-namespaced-hashable-bound.md) | A namespaced `XPHP\Hashable` value-equality bound | Superseded by 0017 | +| [0017](0017-no-special-cased-value-equality-bound.md) | No special-cased value-equality bound (use ordinary generics) | Accepted | diff --git a/docs/caveats.md b/docs/caveats.md index 9e30a18..21454c5 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -465,38 +465,39 @@ class Map { PHP array keys are `int|string` only, so `$this->items[$k] = $v` works only when `K` is a string/int — it **fatals for object keys**. For a -container keyed on (or deduplicating) arbitrary objects, bound the type -parameter on `\XPHP\Hashable`, the recognized value-equality bound, and key on -`hashCode()` internally: +container keyed on (or deduplicating) arbitrary objects, define your own +value-equality contract as a generic interface and bound on it, keying on +`hashCode()` internally. xphp special-cases nothing here — this is the same +pattern as a `Comparable` ordering bound (see [type bounds](syntax/type-bounds.md)): ```php -class Map { - private array $buckets = []; - public function set(K $k, V $v): void { $this->buckets[$k->hashCode()] = [$k, $v]; } - public function get(K $k): V { return $this->buckets[$k->hashCode()][1]; } +// Your library/app declares the contract — xphp ships no `Hashable`. +interface Hashable { + public function hashCode(): int|string; + public function equals(T $other): bool; } -``` - -xphp **recognizes** the `\XPHP\Hashable` bound (so `Map` -and `Set` compile and are bound-checked) but ships **no** -runtime `XPHP\Hashable` interface — it's a pure transpiler. The name is -deliberately **namespaced** (not a global `\Hashable`) so it can never collide -with a future PHP-native interface. You (or your collection library) provide the -contract, e.g.: -```php -namespace XPHP; +final class Money implements Hashable { // bare marker — no LSP friction + public function __construct(private int $cents) {} + public function hashCode(): int|string { return $this->cents; } + public function equals(Money $other): bool { return $other->cents === $this->cents; } +} -interface Hashable { - public function hashCode(): int|string; - public function equals(self $other): bool; +// F-bounded: K must be hashable to its own kind. +class Map, V> { + private array $buckets = []; + public function set(K $k, V $v): void { $this->buckets[$k->hashCode()] = [$k, $v]; } + public function get(K $k): V { return $this->buckets[$k->hashCode()][1]; } } ``` -Reference it fully-qualified (`\XPHP\Hashable`) or via `use`, the same as the -built-in `\Stringable` bound. The deduping/keying logic itself is ordinary -runtime code in your container — the bound just gives it a type-checked -contract. +Because a generic interface lowers to an **empty marker** (see ADR-0004), the +implementing class declares `equals(Money $other)` with its **concrete** type and +PHP imposes no signature constraint — exactly how a `Comparable` implementer +writes `compareTo(Money $other)`. The deduping/keying logic itself is ordinary +runtime code in your container; the bound just gives it a compile-time-checked +contract. (The container above is illustrative — xphp is a transpiler and ships +no collection types.) --- diff --git a/docs/roadmap.md b/docs/roadmap.md index 0e5911a..1bd6f35 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -146,11 +146,6 @@ upcoming one. - DNF bound (e.g. `T : (A & B) | C`). - F-bounded recursion (e.g. `T : Comparable`). - Built-in interface whitelist (Stringable / Countable / Iterator / ...). -- `XPHP\Hashable` value-equality bound — `Set` / - `Map` are expressible and compile-time bound-checked. - Namespaced (not a global `\Hashable`) to avoid colliding with a future - PHP-native interface; xphp ships no runtime `XPHP\Hashable`, so you (or your - collection library) provide the `hashCode()` / `equals()` contract. - Error messages reference the source-level instantiation, not the generated hash. diff --git a/src/Transpiler/Monomorphize/TypeHierarchy.php b/src/Transpiler/Monomorphize/TypeHierarchy.php index c161c3c..e3e7506 100644 --- a/src/Transpiler/Monomorphize/TypeHierarchy.php +++ b/src/Transpiler/Monomorphize/TypeHierarchy.php @@ -54,14 +54,6 @@ 'Error', 'BackedEnum', 'UnitEnum', - // Not a PHP-native interface: `XPHP\Hashable` is the recognized value-equality - // bound so `Set` / `Map` are - // expressible and compile-time-checked. It is deliberately *namespaced* (not a - // global `\Hashable`) so it can never collide with a future PHP-native global - // interface — every other entry here is a real PHP global, this one is not. - // xphp ships no runtime `XPHP\Hashable` — the consumer provides the interface - // contract (`hashCode(): int|string`, `equals(self): bool`). - 'XPHP\\Hashable', ]; /** @@ -255,12 +247,9 @@ private function resolveName(Name $name): string $rest = substr($raw, strlen($first)); return $this->useMap[$first] . $rest; } - // Special case: the PHP-native built-in interfaces have no namespace, so an - // unqualified reference to one resolves as-is rather than getting the current - // namespace appended. This is scoped to single-segment names — a namespaced - // built-in like `XPHP\Hashable` is only matched fully-qualified (handled above) - // or via a `use`, never relative, so it follows normal PHP namespacing here. - if (strpos($raw, '\\') === false && in_array($raw, TypeHierarchy::BUILTIN_TYPES, true)) { + // Special case: built-in interfaces have no namespace; if the raw name matches a + // known built-in we resolve as-is rather than appending the current namespace. + if (in_array($raw, TypeHierarchy::BUILTIN_TYPES, true)) { return $raw; } return $this->qualify($raw); diff --git a/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php b/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php index 7b62df5..9c8b0cb 100644 --- a/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php +++ b/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php @@ -57,84 +57,6 @@ public function testBoundIsSatisfiedByImplementingClass(): void self::assertGreaterThan(0, $result->generatedCount); } - public function testHashableBoundIsSatisfiedByImplementingClass(): void - { - // `XPHP\Hashable` is a whitelisted bound name. xphp recognizes it - // even though the interface is provided by the consumer/library and isn't - // in the scanned source set here — so `Set` resolves - // against a class that `implements \XPHP\Hashable`. - $sourceDir = $this->workDir . '/src'; - mkdir($sourceDir, 0o755, true); - $setFile = $sourceDir . '/Set.xphp'; - file_put_contents($setFile, <<<'PHP' - - { - private array $items = []; - public function add(T $x): void { $this->items[] = $x; } - } - PHP); - $userFile = $sourceDir . '/User.xphp'; - file_put_contents($userFile, <<<'PHP' - (); - PHP); - - $compiler = $this->buildCompiler(); - $sources = new FilepathArray($setFile, $userFile, $useFile); - $result = $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); - - // Set specialized — the Hashable bound resolved (User implements it). - self::assertSame(1, $result->generatedCount); - } - - public function testHashableBoundViolationOnNonImplementingClass(): void - { - $sourceDir = $this->workDir . '/src'; - mkdir($sourceDir, 0o755, true); - $setFile = $sourceDir . '/Set.xphp'; - file_put_contents($setFile, <<<'PHP' - - { - public function add(T $x): void {} - } - PHP); - $plainFile = $sourceDir . '/Plain.xphp'; - file_put_contents($plainFile, <<<'PHP' - (); - PHP); - - $compiler = $this->buildCompiler(); - $sources = new FilepathArray($setFile, $plainFile, $useFile); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Generic bound violated'); - $this->expectExceptionMessage('XPHP\\Hashable'); - $compiler->compile($sources, $sourceDir, $this->targetDir, $this->cacheDir); - } - public function testBoundViolationOnScalarConcreteFailsCompilationWithClearMessage(): void { $sourceDir = $this->workDir . '/src'; diff --git a/test/Transpiler/Monomorphize/TypeHierarchyTest.php b/test/Transpiler/Monomorphize/TypeHierarchyTest.php index 76a56c0..37fa461 100644 --- a/test/Transpiler/Monomorphize/TypeHierarchyTest.php +++ b/test/Transpiler/Monomorphize/TypeHierarchyTest.php @@ -265,32 +265,4 @@ public function testAncestorChainNormalizesLeadingBackslash(): void self::assertSame(['App\\Sup'], $hierarchy->ancestorChain('\\App\\Sub')); } - public function testHashableIsAWhitelistedBoundName(): void - { - // `XPHP\Hashable` is a recognized bound name even though it isn't a PHP - // built-in and isn't declared in the source set — a class implementing - // it satisfies the bound; a known class that doesn't is rejected. It is - // namespaced (not a global `\Hashable`) to avoid ever colliding with a - // future PHP-native interface. - $hierarchy = new TypeHierarchy([ - 'App\\User' => ['XPHP\\Hashable'], - 'App\\Plain' => [], - ]); - - self::assertTrue($hierarchy->isDeclared('XPHP\\Hashable')); - self::assertTrue($hierarchy->isSubtype('App\\User', 'XPHP\\Hashable')); - self::assertFalse($hierarchy->isSubtype('App\\Plain', 'XPHP\\Hashable')); - } - - public function testBareGlobalHashableIsNotMagic(): void - { - // A bare global `\Hashable` is no longer special — only the namespaced - // `XPHP\Hashable` is the recognized value-equality bound, so an unknown - // bare `Hashable` is not a known type. - $hierarchy = new TypeHierarchy([ - 'App\\Plain' => [], - ]); - - self::assertFalse($hierarchy->isDeclared('Hashable')); - } } From ad052456b441634fc8f8baa0d6e6886a5678bad9 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 21 Jun 2026 20:16:27 +0000 Subject: [PATCH 061/114] docs(adr): consolidate the value-equality ADRs and fix forward-references Two cleanups to the ADR log: - Collapse the value-equality pair into one record. ADR-0016 (namespaced `XPHP\Hashable`) was created and superseded entirely before release, so it was pure churn no reader ever saw. Remove it and renumber the replacement to 0016 ("No special-cased value-equality bound"), folding the brief namespacing history into its Context prose. One ADR instead of a dead one plus its successor. - Stop ADR-0013 from forward-referencing a later ADR through its body. The private-property refinement (ADR-0015) was threaded as four inline links into 0013's decision text. Replace them with a single `Amended by ADR-0015` header note and a See-also entry, and let 0015 carry the back-reference. Forward pointers now live only in the header/pointers sections, not the decision prose. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...nstructor-parameters-on-variant-classes.md | 22 +++-- docs/adr/0016-namespaced-hashable-bound.md | 94 ------------------- ...-no-special-cased-value-equality-bound.md} | 23 +++-- docs/adr/README.md | 3 +- 4 files changed, 24 insertions(+), 118 deletions(-) delete mode 100644 docs/adr/0016-namespaced-hashable-bound.md rename docs/adr/{0017-no-special-cased-value-equality-bound.md => 0016-no-special-cased-value-equality-bound.md} (84%) diff --git a/docs/adr/0013-typed-constructor-parameters-on-variant-classes.md b/docs/adr/0013-typed-constructor-parameters-on-variant-classes.md index 6ab9202..5129a82 100644 --- a/docs/adr/0013-typed-constructor-parameters-on-variant-classes.md +++ b/docs/adr/0013-typed-constructor-parameters-on-variant-classes.md @@ -1,6 +1,8 @@ # 13. Typed constructor parameters on variant classes - Status: Accepted — 2026-06 +- Amended by [ADR-0015](0015-variance-markers-on-private-properties.md) — 2026-06 (the property + rule below was later relaxed for *private* properties) ## Context and Problem Statement @@ -60,9 +62,9 @@ invariant** — mutable, `readonly`, and public/protected *promoted* constructor chain *for visible members* (`Type of Child::$item must be …`), so a `T`-typed visible property genuinely fatals — there is no way to carry a real `T` there. Such a property is rejected at compile time (not erased). A **private** property is the exception — PHP does -not type-check private slots across the chain, so it carries a real `T` soundly; see -[ADR-0015](0015-variance-markers-on-private-properties.md). Non-bare shapes in a -constructor parameter (`?T`, `Box`, `T|X`) are not yet supported and stay rejected. +not type-check private slots across the chain, so it carries a real `T` soundly. Non-bare +shapes in a constructor parameter (`?T`, `Box`, `T|X`) are not yet supported and stay +rejected. ### Consequences @@ -72,9 +74,9 @@ constructor parameter (`?T`, `Box`, `T|X`) are not yet supported and stay rej - Good: nothing is erased — the declared type survives into the emitted signature. - Trade-off: a `T`-typed *visible* (public/protected) property is rejected, because PHP property invariance across the edge is unavoidable for visible members. A `T`-typed - *private* property is allowed (see [ADR-0015](0015-variance-markers-on-private-properties.md)); - a *multi-element* collection still hand-rolls a `mixed`/`array` backing plus a covariant - `get(): T`, since many elements can't live in one `private T` slot. + *private* property is allowed; a *multi-element* collection still hand-rolls a + `mixed`/`array` backing plus a covariant `get(): T`, since many elements can't live in one + `private T` slot. - Trade-off: richer constructor-parameter shapes (`?T`, `Box`, `T|X`) aren't supported yet, only a bare variance-marked type parameter. @@ -82,8 +84,7 @@ constructor parameter (`?T`, `Box`, `T|X`) are not yet supported and stay rej `VariancePositionValidator::checkMethod` allows a plain constructor parameter of a variant class at any variance (a public/protected promoted one stays invariant; a private promoted -one is exempt — see [ADR-0015](0015-variance-markers-on-private-properties.md)); -`InnerVarianceValidator` skips a bare variance-marked constructor parameter +one is exempt); `InnerVarianceValidator` skips a bare variance-marked constructor parameter (`isExemptVariantConstructorParam`) and still rejects the non-bare shapes. [`Specializer::specialize`](../../src/Transpiler/Monomorphize/Specializer.php) substitutes the real type into the constructor parameter — no erasure step. Tests compile a covariant `ImmutableList<+T>` and assert each specialization's constructor keeps its real @@ -98,8 +99,7 @@ autoloads and constructs equally cleanly. - Good: keeps the real type and a runtime check; no autoload fatal; localized to the specializer (just normal substitution). - Bad: visible (public/protected) properties still can't carry a real `T` (a *private* one - can — [ADR-0015](0015-variance-markers-on-private-properties.md)); non-bare constructor - shapes not yet supported. + can); non-bare constructor shapes not yet supported. ### Variance-erased constructor parameter @@ -118,4 +118,6 @@ autoloads and constructs equally cleanly. `extends` edges) exist at all. - [ADR-0014](0014-variance-markers-are-class-level-only.md) — why variance stays at the class level (the related boundary). +- [ADR-0015](0015-variance-markers-on-private-properties.md) — the later refinement that + relaxed the property rule for *private* properties. - [Variance](../syntax/variance.md) — the position rules and the typed-construction pattern. diff --git a/docs/adr/0016-namespaced-hashable-bound.md b/docs/adr/0016-namespaced-hashable-bound.md deleted file mode 100644 index 5f6ccd2..0000000 --- a/docs/adr/0016-namespaced-hashable-bound.md +++ /dev/null @@ -1,94 +0,0 @@ -# 16. A namespaced `XPHP\Hashable` value-equality bound - -- Status: Superseded by [ADR-0017](0017-no-special-cased-value-equality-bound.md) — 2026-06 - -## Context and Problem Statement - -PHP array keys are `int|string` only, so a generic container that keys on or -deduplicates **arbitrary objects** needs a value-equality contract — a -`hashCode()` / `equals()` pair — to bound its key parameter on. xphp recognizes -such a bound so `Set` / `Map` are expressible and -compile-time bound-checked, without xphp shipping any runtime interface (bounds -are nominally erased — [ADR-0005](0005-nominal-erased-bound-checking.md)). - -The recognized name lives in the compiler's built-in-type whitelist -(`TypeHierarchy::BUILTIN_TYPES`), alongside the real PHP-native interfaces -(`Stringable`, `Countable`, …) that user code can implement without xphp seeing -their declaration. The question is **what to call it**. It was originally the -global `Hashable`. Every other whitelist entry is a real PHP global interface that -PHP itself guarantees at that name; `Hashable` is the only invented one, and a -global `\Hashable` is a generic, collision-prone name. - -## Decision Drivers - -- A name xphp invents must not squat a global identifier PHP (or a widely-used - library) might later define with a *different* contract — bounds are erased, so - xphp would silently conflate the two and enforce nothing. -- Keep xphp a pure transpiler: it should ship no runtime interface; the consumer - (or their collection library) provides the contract. -- Stay vendor-neutral — don't hard-code one specific library's interface as the - only recognized value-equality bound. - -## Considered Options - -- **Global `\Hashable`** (the original) — simplest, but squats a generic global - name and is the lone non-native entry in a whitelist of real PHP globals. -- **`Ds\Hashable`** (the established ext-ds / php-ds interface) — namespaced and - real, but hard-codes one library's interface as the blessed bound. -- **A namespaced, vendor-neutral `XPHP\Hashable`** — recognized under xphp's own - namespace, provided by the consumer. - -## Decision Outcome - -Chosen: **recognize a namespaced `XPHP\Hashable`**, dropping the magic from the -global `Hashable`. It is referenced fully-qualified (`\XPHP\Hashable`) or via a -`use`, exactly like the built-in `\Stringable` bound, and the consumer provides -the interface (xphp still ships nothing): - -```php -namespace XPHP; - -interface Hashable { - public function hashCode(): int|string; - public function equals(self $other): bool; -} -``` - -Namespacing makes a collision with a future PHP-native global interface -structurally impossible — PHP reserves the global namespace, not `XPHP\`. The name -stays vendor-neutral (it doesn't bless php-ds or any other library), while keeping -xphp a pure transpiler: the `XPHP\Hashable` name is recognized, but no runtime type -is emitted or required from xphp itself. - -The change is free of compatibility cost: the bound was still unreleased when it was -renamed, so no published code depended on the global name. - -### Consequences - -- Good: no global-namespace squat; the recognized bound can never clash with a - future `\Hashable` in PHP core or a popular library. -- Good: bare `Hashable` is no longer magic — inside any namespace it follows normal - PHP name resolution, so a project's own `App\Hashable` is never shadowed. -- Trade-off: the contract lives under xphp's namespace, so a consumer who wants the - bound defines (or aliases) an interface at `XPHP\Hashable`. Acceptable — it's a - one-line interface, and the namespace clearly signals "the bound xphp recognizes." -- Trade-off: not auto-aligned with `Ds\Hashable`; a php-ds user aliases or - re-declares rather than reusing `Ds\Hashable` directly. - -### Confirmation - -The recognized name is the single entry `XPHP\Hashable` in -[`TypeHierarchy::BUILTIN_TYPES`](../../src/Transpiler/Monomorphize/TypeHierarchy.php); -the name-resolution special-case that lets a built-in resolve unqualified is scoped -to single-segment (no-namespace) natives, so a relative `XPHP\Hashable` follows -normal PHP namespacing. Tests pin that `Set` compiles against a -class that `implements \XPHP\Hashable`, that a non-implementing class is rejected -with the bound named in the error, and that a bare global `\Hashable` is no longer a -recognized type. Documented in [Caveats](../caveats.md) and the -[roadmap](../roadmap.md). - -## More Information - -- [ADR-0005](0005-nominal-erased-bound-checking.md) — nominal, erased bound checking - (why xphp recognizes a bound name without enforcing its method shape). -- [Caveats](../caveats.md) — object-keyed containers and the `XPHP\Hashable` bound. diff --git a/docs/adr/0017-no-special-cased-value-equality-bound.md b/docs/adr/0016-no-special-cased-value-equality-bound.md similarity index 84% rename from docs/adr/0017-no-special-cased-value-equality-bound.md rename to docs/adr/0016-no-special-cased-value-equality-bound.md index c289842..b1dc84d 100644 --- a/docs/adr/0017-no-special-cased-value-equality-bound.md +++ b/docs/adr/0016-no-special-cased-value-equality-bound.md @@ -1,17 +1,17 @@ -# 17. No special-cased value-equality bound — use ordinary generics +# 16. No special-cased value-equality bound — use ordinary generics - Status: Accepted — 2026-06 -- Supersedes: [ADR-0016](0016-namespaced-hashable-bound.md) ## Context and Problem Statement A generic container that keys on or deduplicates **arbitrary objects** needs a value-equality contract (a `hashCode()` / `equals()` pair), because PHP array keys are `int|string` only. xphp recognized such a contract by **whitelisting** a `Hashable` name in -`TypeHierarchy::BUILTIN_TYPES` — first as the global `Hashable`, then (ADR-0016) as the -namespaced `XPHP\Hashable` — so a bound like `Set` would compile and be -bound-checked without the interface being in the scanned sources, and without xphp shipping a -runtime type. +`TypeHierarchy::BUILTIN_TYPES` — first as the global `Hashable`, then (briefly) as a +namespaced `XPHP\Hashable` to dodge a global-namespace collision — so a bound like +`Set` would compile and be bound-checked without the interface being in +the scanned sources, and without xphp shipping a runtime type. This ADR replaces that whole +line of thinking. This left `Hashable` as the **only invented, non-PHP-native** entry in a whitelist otherwise made of real PHP global interfaces (`Stringable`, `Countable`, …). Its direct analog — `Comparable`, @@ -40,8 +40,8 @@ make generics work, not to carry a domain-specific contract. ## Considered Options -- **Keep the whitelisted `XPHP\Hashable`** (ADR-0016) — zero-setup static recognition, but a - privileged invented name, static-only, and no generic form. +- **Keep the whitelisted `XPHP\Hashable`** (the prior approach) — zero-setup static recognition, + but a privileged invented name, static-only, and no generic form. - **Ship a runtime `XPHP\Hashable` interface** — makes the name real, but fixes one contract shape for everyone and reverses xphp's pure-transpiler stance. - **Special-case nothing; value-equality is an ordinary library generic** — a library declares @@ -65,9 +65,9 @@ interface (ADR-0004), so the implementing class declares `equals(Money $other)` type under no LSP obligation — identical to how a `Comparable` implementer writes `compareTo(Money $other)`. The bound is checked nominally and erased (ADR-0005). -This supersedes ADR-0016: rather than make a privileged name collision-safe, we drop the privilege -entirely. The capability the original ticket asked for — a compile-time-checked value-equality -bound — remains fully available, now uniform with the rest of the bound surface. +Rather than make a privileged name collision-safe (an earlier iteration of this decision), we drop +the privilege entirely. The capability the original ask wanted — a compile-time-checked +value-equality bound — remains fully available, now uniform with the rest of the bound surface. ### Consequences @@ -94,7 +94,6 @@ marker is empty. Documented in [Caveats](../caveats.md); ordering/value-equality ## More Information -- [ADR-0016](0016-namespaced-hashable-bound.md) — the superseded namespaced-whitelist decision. - [ADR-0004](0004-marker-interfaces-for-instanceof.md) — generic templates lower to empty marker interfaces (why a concrete `equals(T)` has no LSP obligation). - [ADR-0005](0005-nominal-erased-bound-checking.md) — nominal, erased bound checking. diff --git a/docs/adr/README.md b/docs/adr/README.md index 9976f5a..193aabd 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -33,5 +33,4 @@ should be added here as a new numbered file; copy | [0013](0013-typed-constructor-parameters-on-variant-classes.md) | Typed constructor parameters on variant classes | Accepted | | [0014](0014-variance-markers-are-class-level-only.md) | Variance markers are class-level only | Accepted | | [0015](0015-variance-markers-on-private-properties.md) | Variance markers on private properties | Accepted | -| [0016](0016-namespaced-hashable-bound.md) | A namespaced `XPHP\Hashable` value-equality bound | Superseded by 0017 | -| [0017](0017-no-special-cased-value-equality-bound.md) | No special-cased value-equality bound (use ordinary generics) | Accepted | +| [0016](0016-no-special-cased-value-equality-bound.md) | No special-cased value-equality bound (use ordinary generics) | Accepted | From aad2b7ec9f2d6bc9cd7cbf709b2936d51902e7a2 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 21 Jun 2026 23:08:45 +0000 Subject: [PATCH 062/114] feat(check): warn when a variant template's element type isn't in the source set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A covariant/contravariant `extends` edge between two specializations only emits when `TypeHierarchy::isSubtype` can prove the element relationship. When an element type is a plain-PHP class that isn't in the compiled source set (and isn't a PHP built-in), that verdict is unprovable and the edge is silently skipped — autoload-safe, but covariance then degrades into a runtime `TypeError` far from the cause, with no compile-time signal. The bounds checker already rejects the same unprovable verdict loudly; this brings variance in line. `xphp check` now reports it as a non-failing Warning (`xphp.variance_edge_unprovable`) at the instantiation site, naming the type and pointing at "add it to the source set." Detection rides inline in `Registry::recordInstantiation` (where the call-site location lives and which runs in both check and compile): per instantiation, for each covariant/contravariant position, a non-scalar, non-type-param, non-generic leaf arg that the hierarchy doesn't know is flagged. Each external type is flagged at its own site, so every unprovable edge is covered without pairwise correlation. No unsound edge is ever emitted; compile output is unchanged (no warning sink there yet, by design). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/syntax/type-bounds.md | 4 +- docs/syntax/variance.md | 16 ++ src/Transpiler/Monomorphize/Registry.php | 94 +++++++ .../Monomorphize/CheckPassIntegrationTest.php | 47 ++++ .../RegistryVarianceEdgeDiagnosticTest.php | 248 ++++++++++++++++++ .../variance_edge_provable/source/Fruit.xphp | 11 + .../source/Producer.xphp | 13 + .../variance_edge_provable/source/Use.xphp | 8 + .../source/Producer.xphp | 15 ++ .../variance_edge_unprovable/source/Use.xphp | 10 + 10 files changed, 465 insertions(+), 1 deletion(-) create mode 100644 test/Transpiler/Monomorphize/RegistryVarianceEdgeDiagnosticTest.php create mode 100644 test/fixture/check/variance_edge_provable/source/Fruit.xphp create mode 100644 test/fixture/check/variance_edge_provable/source/Producer.xphp create mode 100644 test/fixture/check/variance_edge_provable/source/Use.xphp create mode 100644 test/fixture/check/variance_edge_unprovable/source/Producer.xphp create mode 100644 test/fixture/check/variance_edge_unprovable/source/Use.xphp diff --git a/docs/syntax/type-bounds.md b/docs/syntax/type-bounds.md index 0ff0628..1c19eb2 100644 --- a/docs/syntax/type-bounds.md +++ b/docs/syntax/type-bounds.md @@ -83,7 +83,9 @@ once it sees `public int $value`. - A concrete arg that the compiler can't reason about (not in the source set, not a built-in) fails with a clear "cannot prove satisfaction" message — widen the bound or add the type to the - source set. + source set. The same "not in the source set" condition on a + *variance* type argument is a non-failing warning rather than an + error — see [variance](variance.md#unprovable-variance-edges). - Bounds are an **invariant position** for variance markers — `+T` or `-T` are rejected inside a bound expression. See [variance](variance.md). diff --git a/docs/syntax/variance.md b/docs/syntax/variance.md index c8d10e5..5baee7f 100644 --- a/docs/syntax/variance.md +++ b/docs/syntax/variance.md @@ -70,6 +70,22 @@ including reflection and `instanceof`. For contravariant `-T`, the edge flips: `Consumer extends Consumer`. +### Unprovable variance edges + +An `extends` edge only emits when the compiler can **prove** the element relationship +from the source set. If an element type is not in the compiled `.xphp` source set and +isn't a recognized PHP built-in — typically a plain-`.php` domain class — the compiler +can't prove it, so the edge is silently skipped. The specializations still work in +isolation, but they aren't linked: passing a `Producer` where a +`Producer` is expected then fails at **runtime** with a `TypeError`, because the +covariant edge never formed. + +`xphp check` reports this as a **non-failing warning** at the instantiation site +(`xphp.variance_edge_unprovable`), naming the type and pointing at "add it to the source +set." It mirrors the bounds "cannot prove satisfaction" check for the same condition (see +[type bounds](type-bounds.md)); the fix is the same — include the element type in the +source set the compiler builds its hierarchy from, so the edge can be proven and emitted. + ## Rules Position rules, enforced at compile time over the collected definitions diff --git a/src/Transpiler/Monomorphize/Registry.php b/src/Transpiler/Monomorphize/Registry.php index 896e4f1..517f24e 100644 --- a/src/Transpiler/Monomorphize/Registry.php +++ b/src/Transpiler/Monomorphize/Registry.php @@ -27,6 +27,7 @@ final class Registry public const CODE_TOO_MANY_TYPE_ARGUMENTS = 'xphp.too_many_type_arguments'; public const CODE_DEFAULT_BOUND_VIOLATION = 'xphp.default_bound_violation'; public const CODE_UNDEFINED_TEMPLATE = 'xphp.undefined_template'; + public const CODE_VARIANCE_EDGE_UNPROVABLE = 'xphp.variance_edge_unprovable'; /** * All specialized classes live under this namespace prefix; the full target FQCN @@ -128,6 +129,7 @@ public function recordInstantiation( } $this->validateBounds($templateFqn, $args, $callSite); + $this->validateVarianceEdgeProvability($templateFqn, $args, $callSite); $generatedFqn = self::generatedFqn($templateFqn, $args, $this->hashLength); $template = ltrim($templateFqn, '\\'); @@ -523,6 +525,98 @@ private function validateBounds(string $templateFqn, array $args, ?SourceLocatio ); } + /** + * Warn (don't fail) when a variant template is instantiated over an element type the + * compiler can't see, so its covariant/contravariant `extends` edge is silently dropped. + * + * A covariant/contravariant `extends` edge between two specializations only emits when + * `TypeHierarchy::isSubtype` can *prove* the element relationship. When an element type is + * not in the `.xphp` source set and not a PHP built-in, that verdict is `null` and + * `VarianceEdgeEmitter` skips the edge — autoload-safe, but the author gets no signal and + * covariance degrades into a runtime `TypeError` far from the cause. The *bounds* path + * already rejects the same `null` verdict loudly; this mirrors that contract for variance + * with a non-failing Warning. + * + * Per-instantiation, single-endpoint: each unprovable element type is flagged at its own + * instantiation site, which collectively covers every unprovable edge while keeping the + * call-site location (a deferred pass would lose it — `GenericInstantiation` doesn't retain + * it). Leaf-only: a nested same-template generic arg (`Producer>`) is covered when + * its own recursive instantiation is recorded above, where `Box`'s own `Book` arg is checked. + * + * A Warning only has a sink in check-mode (a collector is attached); `xphp compile` builds + * the Registry without one, and its edge-skipping output is unchanged. (Note: `CallSiteRewriter` + * re-records instantiations location-less in compile Phase 3 — harmless while compile has no + * sink; a future compile sink must account for it.) + * + * @param list $args + */ + private function validateVarianceEdgeProvability(string $templateFqn, array $args, ?SourceLocation $callSite = null): void + { + if ($this->hierarchy === null || $this->diagnostics === null) { + return; + } + $definition = $this->definitions[ltrim($templateFqn, '\\')] ?? null; + if ($definition === null || count($definition->typeParams) !== count($args)) { + return; + } + foreach ($definition->typeParams as $i => $param) { + // Only covariant/contravariant positions form `extends` edges; an invariant + // position requires identical args, so an unprovable type loses no edge there. + if ($param->variance === Variance::Invariant) { + continue; + } + $arg = $args[$i]; + // Scalars and type-params never form class edges; a generic arg is leaf-only-deferred + // (its inner leaves are checked when its own instantiation is recorded). + if ($arg->isScalar || $arg->isTypeParam || $arg->isGeneric()) { + continue; + } + // A type known to the hierarchy (in-source or a PHP built-in) yields a provable + // true/false verdict — only the "not declared" case is the unprovable `null`. + if ($this->hierarchy->isDeclared($arg->name)) { + continue; + } + $this->diagnostics->add(new Diagnostic( + Severity::Warning, + self::CODE_VARIANCE_EDGE_UNPROVABLE, + self::varianceEdgeUnprovableMessage( + self::formatInstantiation(ltrim($templateFqn, '\\'), $args), + $param->name, + $param->variance, + $arg->toDisplayString(), + ), + $callSite, + )); + } + } + + /** + * User-facing text for an unprovable variance edge. Mirrors the bounds "not in the source + * set … cannot prove" phrasing so the two read consistently; kept as its own builder because + * the variance message names the parameter's variance rather than a bound. + */ + private static function varianceEdgeUnprovableMessage( + string $instantiationLabel, + string $paramName, + Variance $variance, + string $typeDisplay, + ): string { + $marker = $variance === Variance::Covariant ? '+' : '-'; + return sprintf( + "Variance edge cannot be proven while instantiating %s.\n" + . " type parameter %s%s is %s, but %s is not in the source set the hierarchy was built from (and is not a recognized PHP built-in),\n" + . " so the compiler cannot prove its subtype edges — this specialization is not linked to related ones and the %s relationship silently does not apply at runtime.\n\n" + . " Add %s to the source set the hierarchy is built from to enable the edge.", + $instantiationLabel, + $marker, + $paramName, + $variance->value, + $typeDisplay, + $variance->value, + $typeDisplay, + ); + } + /** * Reusable bound check for any (typeParams, concreteArgs) pair against a hierarchy. * diff --git a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php index cb9f357..6e8131e 100644 --- a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php +++ b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php @@ -60,6 +60,53 @@ public function testVariancePositionViolationIsCollectedByCheck(): void self::assertSame(VariancePositionValidator::CODE_VARIANCE_POSITION, $diagnostics->all()[0]->code); } + public function testVarianceEdgeUnprovableIsReportedAsNonFailingWarning(): void + { + // A covariant template instantiated over an element type not in the source set: + // the `extends` edge is silently dropped today; check now reports it as a + // non-failing Warning at the instantiation site (so exit stays 0). + $diagnostics = $this->check('variance_edge_unprovable'); + + self::assertFalse($diagnostics->hasErrors(), 'a warning must not fail the gate'); + self::assertCount(1, $diagnostics->all()); + $d = $diagnostics->all()[0]; + self::assertSame(Registry::CODE_VARIANCE_EDGE_UNPROVABLE, $d->code); + self::assertSame(\XPHP\Diagnostics\Severity::Warning, $d->severity); + self::assertNotNull($d->location); + self::assertStringEndsWith('Use.xphp', $d->location->file); + self::assertStringContainsString('Book', $d->message); + self::assertStringContainsString('not in the source set', $d->message); + } + + public function testVarianceEdgeProvableTypesProduceNoWarning(): void + { + // Same covariant template, but the element type IS declared in the source set — + // its edges are provable, so nothing is reported. + $diagnostics = $this->check('variance_edge_provable'); + + self::assertFalse($diagnostics->hasErrors()); + self::assertSame([], $diagnostics->all()); + } + + public function testCompileDoesNotFailOnUnprovableVarianceEdge(): void + { + // Compile has no warning sink; the unprovable edge is skipped exactly as before + // (autoload-safe), and compilation succeeds without throwing. + $work = sys_get_temp_dir() . '/xphp-check-compile-' . uniqid('', true); + mkdir($work, 0o755, true); + try { + $result = $this->buildCompiler()->compile( + $this->sources('variance_edge_unprovable'), + $this->sourceDir('variance_edge_unprovable'), + $work . '/dist', + $work . '/cache', + ); + self::assertGreaterThan(0, $result->generatedCount); + } finally { + self::rrmdir($work); + } + } + public function testCompileStillThrowsOnVariancePositionViolation(): void { $this->expectException(RuntimeException::class); diff --git a/test/Transpiler/Monomorphize/RegistryVarianceEdgeDiagnosticTest.php b/test/Transpiler/Monomorphize/RegistryVarianceEdgeDiagnosticTest.php new file mode 100644 index 0000000..fbce2d6 --- /dev/null +++ b/test/Transpiler/Monomorphize/RegistryVarianceEdgeDiagnosticTest.php @@ -0,0 +1,248 @@ +registry($collector)->recordInstantiation( + 'App\\Producer', + [new TypeRef('App\\Book')], + $loc, + ); + + self::assertFalse($collector->hasErrors(), 'a warning must not fail the gate'); + self::assertCount(1, $collector->all()); + $d = $collector->all()[0]; + self::assertSame(Severity::Warning, $d->severity); + self::assertSame(Registry::CODE_VARIANCE_EDGE_UNPROVABLE, $d->code); + self::assertSame($loc, $d->location); + } + + public function testWarningMessageHasExactText(): void + { + $collector = new DiagnosticCollector(); + $this->registry($collector)->recordInstantiation('App\\Producer', [new TypeRef('App\\Book')]); + + $expected = <<<'TXT' + Variance edge cannot be proven while instantiating App\Producer. + type parameter +T is covariant, but App\Book is not in the source set the hierarchy was built from (and is not a recognized PHP built-in), + so the compiler cannot prove its subtype edges — this specialization is not linked to related ones and the covariant relationship silently does not apply at runtime. + + Add App\Book to the source set the hierarchy is built from to enable the edge. + TXT; + + self::assertSame($expected, $collector->all()[0]->message); + } + + public function testContravariantOverUnprovableLeafIsWarnedWithMinusMarker(): void + { + $collector = new DiagnosticCollector(); + $this->registry($collector)->recordInstantiation('App\\Consumer', [new TypeRef('App\\Book')]); + + self::assertCount(1, $collector->all()); + self::assertStringContainsString('type parameter -T is contravariant', $collector->all()[0]->message); + } + + public function testProvableDeclaredLeafIsSilent(): void + { + // `App\Banana` is in the hierarchy → provable verdict, no warning. + $collector = new DiagnosticCollector(); + $this->registry($collector)->recordInstantiation('App\\Producer', [new TypeRef('App\\Banana')]); + + self::assertSame([], $collector->all()); + } + + public function testInvariantPositionIsSilent(): void + { + // Invariant positions form no edge, so an unprovable type there loses nothing. + $collector = new DiagnosticCollector(); + $this->registry($collector)->recordInstantiation('App\\Box', [new TypeRef('App\\Book')]); + + self::assertSame([], $collector->all()); + } + + public function testScalarArgIsSilent(): void + { + $collector = new DiagnosticCollector(); + $this->registry($collector)->recordInstantiation('App\\Producer', [new TypeRef('int', isScalar: true)]); + + self::assertSame([], $collector->all()); + } + + public function testTypeParamArgIsSilent(): void + { + $collector = new DiagnosticCollector(); + $this->registry($collector)->recordInstantiation('App\\Producer', [new TypeRef('T', isTypeParam: true)]); + + self::assertSame([], $collector->all()); + } + + public function testGenericArgIsSilentLeafOnly(): void + { + // A generic arg is leaf-only-deferred: its inner leaves are checked when its own + // instantiation is recorded, not at the outer level — even when its template name + // (`App\External`) is itself not in the source set. + $collector = new DiagnosticCollector(); + $this->registry($collector)->recordInstantiation( + 'App\\Producer', + [new TypeRef('App\\External', [new TypeRef('App\\Banana')])], + ); + + self::assertSame([], $collector->all()); + } + + public function testTwoVariantPositionsEachUnprovableWarnTwice(): void + { + // `Pair<+A, +B>` over two unprovable leaves → one warning per covariant position. + $collector = new DiagnosticCollector(); + $this->registry($collector)->recordInstantiation( + 'App\\Pair', + [new TypeRef('App\\Book'), new TypeRef('App\\Author')], + ); + + self::assertCount(2, $collector->all()); + foreach ($collector->all() as $d) { + self::assertSame(Registry::CODE_VARIANCE_EDGE_UNPROVABLE, $d->code); + } + } + + public function testLeadingBackslashTemplateNameResolvesAndIsNormalisedInMessage(): void + { + // The template FQN may arrive with a leading backslash; it must still resolve to the + // recorded definition (so the warning fires) and the message must name it normalised + // (no leading backslash), matching the bounds path. + $collector = new DiagnosticCollector(); + $this->registry($collector)->recordInstantiation('\\App\\Producer', [new TypeRef('App\\Book')]); + + self::assertCount(1, $collector->all()); + self::assertStringContainsString('instantiating App\\Producer', $collector->all()[0]->message); + } + + public function testEarlierInvariantPositionDoesNotShortCircuitLaterVariant(): void + { + // `Mixed`: the invariant A is skipped, but the walk must continue to the + // covariant B and still warn (pins `continue`, not `break`, on the invariant skip). + $collector = new DiagnosticCollector(); + $this->registry($collector)->recordInstantiation( + 'App\\Mixed', + [new TypeRef('App\\Book'), new TypeRef('App\\Author')], + ); + + self::assertCount(1, $collector->all()); + self::assertStringContainsString('App\\Author', $collector->all()[0]->message); + } + + public function testEarlierScalarPositionDoesNotShortCircuitLaterVariant(): void + { + // `Pair<+A, +B>` with a scalar A: A is skipped, B still warns (pins `continue` on + // the scalar/type-param/generic skip). + $collector = new DiagnosticCollector(); + $this->registry($collector)->recordInstantiation( + 'App\\Pair', + [new TypeRef('int', isScalar: true), new TypeRef('App\\Book')], + ); + + self::assertCount(1, $collector->all()); + self::assertStringContainsString('App\\Book', $collector->all()[0]->message); + } + + public function testEarlierDeclaredPositionDoesNotShortCircuitLaterVariant(): void + { + // `Pair<+A, +B>` with a declared A: A is skipped (provable), B still warns (pins + // `continue` on the isDeclared skip). + $collector = new DiagnosticCollector(); + $this->registry($collector)->recordInstantiation( + 'App\\Pair', + [new TypeRef('App\\Banana'), new TypeRef('App\\Book')], + ); + + self::assertCount(1, $collector->all()); + self::assertStringContainsString('App\\Book', $collector->all()[0]->message); + } + + public function testCompileModeNeverThrowsAndEmitsNothing(): void + { + // No collector (compile): the warning has no sink, so recording completes silently. + $registry = $this->registry(null); + $registry->recordInstantiation('App\\Producer', [new TypeRef('App\\Book')]); + + self::assertCount(1, $registry->instantiations()); + } + + private function registry(?DiagnosticCollector $collector): Registry + { + $hierarchy = new TypeHierarchy([ + 'App\\Fruit' => [], + 'App\\Banana' => ['App\\Fruit'], + ]); + $registry = new Registry(hierarchy: $hierarchy, diagnostics: $collector); + $registry->recordDefinition( + 'App\\Producer', + 'Producer', + [new TypeParam('T', null, null, Variance::Covariant)], + new Class_(new Identifier('Producer')), + '/App/Producer.xphp', + ); + $registry->recordDefinition( + 'App\\Consumer', + 'Consumer', + [new TypeParam('T', null, null, Variance::Contravariant)], + new Class_(new Identifier('Consumer')), + '/App/Consumer.xphp', + ); + $registry->recordDefinition( + 'App\\Box', + 'Box', + [new TypeParam('T', null, null, Variance::Invariant)], + new Class_(new Identifier('Box')), + '/App/Box.xphp', + ); + $registry->recordDefinition( + 'App\\Pair', + 'Pair', + [ + new TypeParam('A', null, null, Variance::Covariant), + new TypeParam('B', null, null, Variance::Covariant), + ], + new Class_(new Identifier('Pair')), + '/App/Pair.xphp', + ); + // Invariant first, covariant second — to prove an earlier skipped position + // doesn't short-circuit the walk before a later variant position. + $registry->recordDefinition( + 'App\\Mixed', + 'Mixed', + [ + new TypeParam('A', null, null, Variance::Invariant), + new TypeParam('B', null, null, Variance::Covariant), + ], + new Class_(new Identifier('Mixed')), + '/App/Mixed.xphp', + ); + + return $registry; + } +} diff --git a/test/fixture/check/variance_edge_provable/source/Fruit.xphp b/test/fixture/check/variance_edge_provable/source/Fruit.xphp new file mode 100644 index 0000000..d9c8deb --- /dev/null +++ b/test/fixture/check/variance_edge_provable/source/Fruit.xphp @@ -0,0 +1,11 @@ + +{ + public function get(): T + { + throw new \LogicException(); + } +} diff --git a/test/fixture/check/variance_edge_provable/source/Use.xphp b/test/fixture/check/variance_edge_provable/source/Use.xphp new file mode 100644 index 0000000..04b515a --- /dev/null +++ b/test/fixture/check/variance_edge_provable/source/Use.xphp @@ -0,0 +1,8 @@ +(); diff --git a/test/fixture/check/variance_edge_unprovable/source/Producer.xphp b/test/fixture/check/variance_edge_unprovable/source/Producer.xphp new file mode 100644 index 0000000..ebca57e --- /dev/null +++ b/test/fixture/check/variance_edge_unprovable/source/Producer.xphp @@ -0,0 +1,15 @@ + +{ + public function get(): T + { + throw new \LogicException(); + } +} diff --git a/test/fixture/check/variance_edge_unprovable/source/Use.xphp b/test/fixture/check/variance_edge_unprovable/source/Use.xphp new file mode 100644 index 0000000..52643a1 --- /dev/null +++ b/test/fixture/check/variance_edge_unprovable/source/Use.xphp @@ -0,0 +1,10 @@ +(); From 1186f80e759720c36179bd06ed25f7ea765b0f81 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 22 Jun 2026 06:14:24 +0000 Subject: [PATCH 063/114] feat(compile): make the emit path root-aware for multi-root builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `compile()` computed every emitted file's relative (PSR-4) path against a single scalar `$sourceDir`, flattening any out-of-root file to `basename()`. That blocks compiling more than one source root in a single invocation — the files of a second root collapse into the target top level. Add an optional `?array $rootByFile` parameter: when null (every existing caller), behavior is byte-for-byte identical to before; when supplied, each file emits relative to its own root, so multiple roots keep their PSR-4 layouts. A genuine same-target-path collision across roots is now a hard error rather than a silent overwrite. The optional map is threaded (additive, default null) through `CompiledWorkspace` and `StaticAnalysisGate` so the check/PHPStan path can use it too. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/StaticAnalysis/CompiledWorkspace.php | 18 +++-- src/StaticAnalysis/StaticAnalysisGate.php | 4 +- src/Transpiler/Monomorphize/Compiler.php | 28 +++++++- .../Monomorphize/CompilerIntegrationTest.php | 71 +++++++++++++++++++ 4 files changed, 114 insertions(+), 7 deletions(-) diff --git a/src/StaticAnalysis/CompiledWorkspace.php b/src/StaticAnalysis/CompiledWorkspace.php index 9c27bb7..25ab976 100644 --- a/src/StaticAnalysis/CompiledWorkspace.php +++ b/src/StaticAnalysis/CompiledWorkspace.php @@ -36,31 +36,41 @@ private function __construct( ) { } - /** Compile into a fresh, uniquely-named directory beneath $tmpBase. */ + /** + * Compile into a fresh, uniquely-named directory beneath $tmpBase. + * + * @param ?array $rootByFile per-file source root for multi-root emit (see Compiler::compile) + */ public static function inTempDir( Compiler $compiler, FilepathArray $sources, string $sourceDir, string $tmpBase, + ?array $rootByFile = null, ): self { $root = rtrim($tmpBase, '/') . '/xphp-check-' . bin2hex(random_bytes(8)); - return self::compile($compiler, $sources, $sourceDir, $root); + return self::compile($compiler, $sources, $sourceDir, $root, $rootByFile); } - /** Compile into an explicit $root (deterministic; used by tests). */ + /** + * Compile into an explicit $root (deterministic; used by tests). + * + * @param ?array $rootByFile per-file source root for multi-root emit (see Compiler::compile) + */ public static function compile( Compiler $compiler, FilepathArray $sources, string $sourceDir, string $root, + ?array $rootByFile = null, ): self { $distDir = $root . '/dist'; $cacheDir = $root . '/cache'; // The Compiler's writer creates intermediate directories as it emits, so // $root needs no pre-creation; an empty source set simply writes nothing. - $result = $compiler->compile($sources, $sourceDir, $distDir, $cacheDir); + $result = $compiler->compile($sources, $sourceDir, $distDir, $cacheDir, $rootByFile); // Canonicalize the analysable dirs: PHPStan reports findings under // realpath()'d paths (symlinks resolved, e.g. macOS /var -> /private/var), diff --git a/src/StaticAnalysis/StaticAnalysisGate.php b/src/StaticAnalysis/StaticAnalysisGate.php index aa6f792..4b20145 100644 --- a/src/StaticAnalysis/StaticAnalysisGate.php +++ b/src/StaticAnalysis/StaticAnalysisGate.php @@ -31,6 +31,7 @@ public function __construct(private Compiler $compiler) } /** + * @param ?array $rootByFile per-file source root for multi-root emit (see Compiler::compile) * @return list */ public function analyze( @@ -39,6 +40,7 @@ public function analyze( string $workingDir, ?string $explicitBin, ?string $explicitConfig, + ?array $rootByFile = null, ): array { $bin = PhpStanLocator::fromEnvironment($workingDir)->locate($explicitBin); if ($bin === null) { @@ -54,7 +56,7 @@ public function analyze( } $config = (new PhpStanConfigResolver($workingDir))->resolve($explicitConfig); - $workspace = CompiledWorkspace::inTempDir($this->compiler, $sources, $sourceDir, sys_get_temp_dir()); + $workspace = CompiledWorkspace::inTempDir($this->compiler, $sources, $sourceDir, sys_get_temp_dir(), $rootByFile); try { $representatives = RepresentativeSelector::select($workspace->registry, $workspace->generatedDir); if ($representatives === []) { diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 6b37851..b2161c3 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -50,11 +50,19 @@ public function __construct( ) { } + /** + * @param ?array $rootByFile Optional map of absolute source filepath → the + * source root it was found under, used to compute each emitted file's relative (PSR-4) path. + * When null (the single-source-dir form), every file is relative to $sourceDir, exactly as + * before. When supplied (manifest / multi-root form), each file is relative to its own root, + * so a second root's files don't flatten; $sourceDir is the fallback for any unmapped file. + */ public function compile( FilepathArray $sources, string $sourceDir, string $targetDir, string $cacheDir, + ?array $rootByFile = null, ): CompileResult { // Phase 0: parse every source up front. The TypeHierarchy (used to validate generic // bounds at recordInstantiation time) needs to see every class/interface/trait @@ -194,14 +202,30 @@ public function compile( $this->specializedClassGenerator->emit($classAst, $generatedFqn, $cacheDir); } - // Phase 4: rewrite + emit user source files. + // Phase 4: rewrite + emit user source files. Each file's relative (PSR-4) path is computed + // against its own source root (the manifest/multi-root form), falling back to $sourceDir + // for the single-dir form. Two roots that would emit a file to the same target path is a + // hard error, not a silent overwrite. + $emittedBy = []; foreach ($astPerFile as $filepath => $ast) { $rewrittenAst = $rewriter->rewrite($ast); $code = $this->printer->prettyPrintFile($rewrittenAst); - $relPath = self::relativePath($sourceDir, $filepath); + $base = $rootByFile[$filepath] ?? $sourceDir; + $relPath = self::relativePath($base, $filepath); $targetPath = rtrim($targetDir, '/') . '/' . preg_replace('/\.xphp$/', '.php', $relPath); + if (isset($emittedBy[$targetPath])) { + throw new RuntimeException(sprintf( + 'Emit path collision: "%s" and "%s" both map to "%s" — two source roots contain ' + . 'a file at the same relative path. Rename one or separate the roots.', + $emittedBy[$targetPath], + $filepath, + $targetPath, + )); + } + $emittedBy[$targetPath] = $filepath; + $targetSubdir = dirname($targetPath); if (!is_dir($targetSubdir)) { mkdir($targetSubdir, 0o755, true); diff --git a/test/Transpiler/Monomorphize/CompilerIntegrationTest.php b/test/Transpiler/Monomorphize/CompilerIntegrationTest.php index 47c0210..09841aa 100644 --- a/test/Transpiler/Monomorphize/CompilerIntegrationTest.php +++ b/test/Transpiler/Monomorphize/CompilerIntegrationTest.php @@ -8,6 +8,7 @@ use PhpParser\PrettyPrinter\Standard as StandardPrinter; use PHPUnit\Framework\TestCase; use XPHP\FileSystem\FileFinder\NativeFileFinder; +use XPHP\FileSystem\FilepathArray; use XPHP\FileSystem\FileReader\NativeFileReader; use XPHP\FileSystem\FileWriter\NativeFileWriter; use XPHP\TestSupport\SnapshotHash; @@ -109,6 +110,76 @@ public function testPsr4SourceLayoutMirrorsToTarget(): void ); } + public function testMultiRootEmitComputesEachFileRelativeToItsOwnRoot(): void + { + // Two source roots: each file must emit relative to ITS root (no flatten). Without the + // root map, the second root's file would collapse to dist/Thing.php via basename(). + $rootA = $this->workDir . '/rootA'; + $rootB = $this->workDir . '/rootB'; + mkdir($rootA . '/Containers', 0o755, true); + mkdir($rootB . '/Models', 0o755, true); + $boxFile = $rootA . '/Containers/Box.xphp'; + $thingFile = $rootB . '/Models/Thing.xphp'; + file_put_contents($boxFile, " $rootA, $thingFile => $rootB]; + + $this->buildCompiler()->compile($sources, $rootA, $this->targetDir, $this->cacheDir, $rootByFile); + + self::assertFileExists($this->targetDir . '/Containers/Box.php', 'rootA file relative to rootA'); + self::assertFileExists($this->targetDir . '/Models/Thing.php', 'rootB file relative to rootB'); + self::assertFileDoesNotExist( + $this->targetDir . '/Thing.php', + 'a second root must not flatten to the target top level', + ); + } + + public function testFileAbsentFromRootMapFallsBackToSourceDir(): void + { + // A file present in $sources but absent from $rootByFile uses $sourceDir as its base + // (the defensive `?? $sourceDir` fallback), while a mapped file uses its own root. + $rootA = $this->workDir . '/fbA'; + $rootB = $this->workDir . '/fbB'; + mkdir($rootA . '/Sub', 0o755, true); + mkdir($rootB . '/Models', 0o755, true); + $mapped = $rootB . '/Models/Mapped.xphp'; + $unmapped = $rootA . '/Sub/Unmapped.xphp'; + file_put_contents($mapped, " $rootB]; + + $this->buildCompiler()->compile($sources, $rootA, $this->targetDir, $this->cacheDir, $rootByFile); + + self::assertFileExists($this->targetDir . '/Models/Mapped.php', 'mapped file relative to its root'); + self::assertFileExists($this->targetDir . '/Sub/Unmapped.php', 'unmapped file relative to $sourceDir fallback'); + } + + public function testEmitPathCollisionAcrossRootsIsAnError(): void + { + // Two roots each holding a file at the same relative path → same target path. That must be + // a hard error, not a silent overwrite. + $rootA = $this->workDir . '/cA'; + $rootB = $this->workDir . '/cB'; + mkdir($rootA, 0o755, true); + mkdir($rootB, 0o755, true); + $a = $rootA . '/Dup.xphp'; + $b = $rootB . '/Dup.xphp'; + file_put_contents($a, " $rootA, $b => $rootB]; + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Emit path collision'); + $this->buildCompiler()->compile($sources, $rootA, $this->targetDir, $this->cacheDir, $rootByFile); + } + public function testGeneratedCodeIsLoadableViaPsr4Autoloader(): void { $compiler = $this->buildCompiler(); From ad62c0f362ca37b7c53b959e1fae8de15ebc7376 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 22 Jun 2026 06:20:34 +0000 Subject: [PATCH 064/114] feat(config): add xphp.json manifest parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `XPHP\Config\Manifest` (a value object: `sources`, `include`, optional `target`/`cache`) and `ManifestParser`, a pure string→VO parser for the `xphp.json` manifest. Plain JSON via the built-in `json_decode` (no extra dependency). Validation is strict on known keys — `sources`/`include` must be arrays of strings, `target`/`cache` strings — with clear, labelled errors; malformed JSON and a non-object top level are rejected (an empty `{}` applies defaults: `sources` = ["."], `include` = []). Unknown keys are ignored for forward-compatibility. This is the parsing half of config-driven source resolution; the filesystem walk (glob discovery, transitive includes) lands next, on top of it. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Config/Manifest.php | 29 ++++++++ src/Config/ManifestParser.php | 87 +++++++++++++++++++++++ test/Config/ManifestParserTest.php | 106 +++++++++++++++++++++++++++++ 3 files changed, 222 insertions(+) create mode 100644 src/Config/Manifest.php create mode 100644 src/Config/ManifestParser.php create mode 100644 test/Config/ManifestParserTest.php diff --git a/src/Config/Manifest.php b/src/Config/Manifest.php new file mode 100644 index 0000000..0dd165c --- /dev/null +++ b/src/Config/Manifest.php @@ -0,0 +1,29 @@ + $sources This package's own `.xphp` source roots (relative). Defaults to + * `["."]` (the manifest's own dir) when the key is absent. + * @param list $include Other packages to pull in transitively — each a dir or a glob + * (`*`/`**`). Defaults to `[]`. + * @param ?string $target Optional build output dir (entry manifest only; CLI overrides). + * @param ?string $cache Optional generated-class cache dir (entry manifest only; CLI overrides). + */ + public function __construct( + public array $sources, + public array $include, + public ?string $target, + public ?string $cache, + ) { + } +} diff --git a/src/Config/ManifestParser.php b/src/Config/ManifestParser.php new file mode 100644 index 0000000..2e4c4a7 --- /dev/null +++ b/src/Config/ManifestParser.php @@ -0,0 +1,87 @@ +getMessage()), previous: $e); + } + + // `{}` and `[]` both decode to `[]`; treat the empty case as an empty object (defaults). + // Only a non-empty JSON array (a list) is a genuine "not an object" error. + if (!is_array($data) || ($data !== [] && array_is_list($data))) { + throw new RuntimeException(sprintf('Invalid %s: top level must be a JSON object.', $label)); + } + + return new Manifest( + self::stringList($data, 'sources', $label) ?? ['.'], + self::stringList($data, 'include', $label) ?? [], + self::optionalString($data, 'target', $label), + self::optionalString($data, 'cache', $label), + ); + } + + /** + * @param array $data + * @return ?list null when the key is absent (caller applies its default). + */ + private static function stringList(array $data, string $key, string $label): ?array + { + if (!array_key_exists($key, $data)) { + return null; + } + $value = $data[$key]; + if (!is_array($value) || !array_is_list($value)) { + throw new RuntimeException(sprintf('Invalid %s: "%s" must be an array of strings.', $label, $key)); + } + $out = []; + foreach ($value as $entry) { + if (!is_string($entry)) { + throw new RuntimeException(sprintf('Invalid %s: "%s" must contain only strings.', $label, $key)); + } + $out[] = $entry; + } + + return $out; + } + + /** + * @param array $data + */ + private static function optionalString(array $data, string $key, string $label): ?string + { + if (!array_key_exists($key, $data)) { + return null; + } + $value = $data[$key]; + if (!is_string($value)) { + throw new RuntimeException(sprintf('Invalid %s: "%s" must be a string.', $label, $key)); + } + + return $value; + } +} diff --git a/test/Config/ManifestParserTest.php b/test/Config/ManifestParserTest.php new file mode 100644 index 0000000..f2bbb3a --- /dev/null +++ b/test/Config/ManifestParserTest.php @@ -0,0 +1,106 @@ +parser = new ManifestParser(); + } + + public function testParsesAFullManifest(): void + { + $m = $this->parser->parse('{"sources":["src","tests"],"include":["vendor/*/*"],"target":"dist","cache":".xphp-cache"}'); + + self::assertSame(['src', 'tests'], $m->sources); + self::assertSame(['vendor/*/*'], $m->include); + self::assertSame('dist', $m->target); + self::assertSame('.xphp-cache', $m->cache); + } + + public function testEmptyObjectAppliesDefaults(): void + { + $m = $this->parser->parse('{}'); + + self::assertSame(['.'], $m->sources, 'sources defaults to the manifest dir'); + self::assertSame([], $m->include); + self::assertNull($m->target); + self::assertNull($m->cache); + } + + public function testOmittedSourcesAndIncludeUseDefaultsWhileOthersSet(): void + { + $m = $this->parser->parse('{"target":"out"}'); + + self::assertSame(['.'], $m->sources); + self::assertSame([], $m->include); + self::assertSame('out', $m->target); + self::assertNull($m->cache); + } + + public function testUnknownKeysAreIgnored(): void + { + $m = $this->parser->parse('{"sources":["src"],"future":{"x":1},"extra":[1,2]}'); + + self::assertSame(['src'], $m->sources); + self::assertSame([], $m->include); + } + + public function testMalformedJsonThrows(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid xphp.json'); + $this->parser->parse('{"sources": ['); + } + + public function testTopLevelArrayIsRejected(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('top level must be a JSON object'); + $this->parser->parse('["src"]'); + } + + public function testNonArraySourcesIsRejected(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('"sources" must be an array of strings'); + $this->parser->parse('{"sources":"src"}'); + } + + public function testAssociativeSourcesIsRejected(): void + { + // A JSON object (non-list) for a list field is rejected. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('"include" must be an array of strings'); + $this->parser->parse('{"include":{"key":"a"}}'); + } + + public function testNonStringEntryInListIsRejected(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('"sources" must contain only strings'); + $this->parser->parse('{"sources":["ok",3]}'); + } + + public function testNonStringTargetIsRejected(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('"target" must be a string'); + $this->parser->parse('{"target":123}'); + } + + public function testCustomLabelAppearsInErrors(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid /pkg/xphp.json'); + $this->parser->parse('{nope', '/pkg/xphp.json'); + } +} From dc2bb2113efb19508aaff0e68a5c508f6c2bd338 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 22 Jun 2026 06:40:06 +0000 Subject: [PATCH 065/114] feat(config): resolve xphp.json manifests into a multi-root source set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `ManifestResolver`, which turns an entry `xphp.json` (a file, or a directory containing one) into a `ResolvedSources`: every `.xphp` file to compile — the package's own `sources` plus every transitively-`include`d package — each mapped to the source root it was found under (for root-aware emit), plus the entry manifest's optional `target`/`cache`. `include` entries may be globs (`*`/`?`/`[…]` via the native `glob`, GLOB_ONLYDIR; recursive `**` is rejected with a clear message). A glob-matched directory without an `xphp.json` is skipped (vendor over-matches by design — `vendor/*/*` discovers installed packages); an explicit, non-glob include lacking one is a hard error. The walk dedups by realpath, so diamonds resolve once and a↔b cycles terminate. Paths and globs resolve against each manifest's own directory; `target`/`cache` come from the entry manifest only. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Config/Manifest.php | 6 +- src/Config/ManifestResolver.php | 193 ++++++++++++++++++++ src/Config/ResolvedSources.php | 28 +++ test/Config/ManifestResolverTest.php | 255 +++++++++++++++++++++++++++ 4 files changed, 479 insertions(+), 3 deletions(-) create mode 100644 src/Config/ManifestResolver.php create mode 100644 src/Config/ResolvedSources.php create mode 100644 test/Config/ManifestResolverTest.php diff --git a/src/Config/Manifest.php b/src/Config/Manifest.php index 0dd165c..ed133b1 100644 --- a/src/Config/Manifest.php +++ b/src/Config/Manifest.php @@ -12,10 +12,10 @@ final readonly class Manifest { /** - * @param list $sources This package's own `.xphp` source roots (relative). Defaults to - * `["."]` (the manifest's own dir) when the key is absent. + * @param list $sources This package's own `.xphp` source roots (relative). The parser + * substitutes `["."]` (the manifest's own dir) when the key is absent. * @param list $include Other packages to pull in transitively — each a dir or a glob - * (`*`/`**`). Defaults to `[]`. + * (`*`/`?`/`[…]` per segment; recursive `**` is rejected). The parser substitutes `[]` when absent. * @param ?string $target Optional build output dir (entry manifest only; CLI overrides). * @param ?string $cache Optional generated-class cache dir (entry manifest only; CLI overrides). */ diff --git a/src/Config/ManifestResolver.php b/src/Config/ManifestResolver.php new file mode 100644 index 0000000..a28bb2b --- /dev/null +++ b/src/Config/ManifestResolver.php @@ -0,0 +1,193 @@ +manifestPathFor($entry); + $entryManifest = $this->parseFile($entryPath); + + $visited = []; + $seenRoots = []; + /** @var array $rootByFile */ + $rootByFile = []; + $this->walk($entryPath, $entryManifest, $visited, $seenRoots, $rootByFile); + + $entryDir = dirname($entryPath); + + return new ResolvedSources( + new FilepathArray(...array_keys($rootByFile)), + $rootByFile, + $entryManifest->target !== null ? self::join($entryDir, $entryManifest->target) : null, + $entryManifest->cache !== null ? self::join($entryDir, $entryManifest->cache) : null, + ); + } + + /** + * @param array $visited realpath of each visited manifest (dedup + cycle guard) + * @param array $seenRoots realpath of each enumerated source root (dedup) + * @param array $rootByFile accumulated filepath → root + */ + private function walk(string $manifestPath, Manifest $manifest, array &$visited, array &$seenRoots, array &$rootByFile): void + { + $key = self::realKey($manifestPath); + if (isset($visited[$key])) { + return; + } + // @infection-ignore-all TrueValue -- dedup/cycle-guard keys on isset(), which tests key + // existence not value, so the assigned literal is immaterial (cycle + diamond tests pin it). + $visited[$key] = true; + $dir = dirname($manifestPath); + + foreach ($manifest->sources as $src) { + $root = self::join($dir, $src); + if (!is_dir($root)) { + throw new RuntimeException(sprintf( + 'Invalid %s: source "%s" is not a directory (%s).', + $manifestPath, + $src, + $root, + )); + } + $rootKey = self::realKey($root); + if (isset($seenRoots[$rootKey])) { + continue; + } + // @infection-ignore-all TrueValue -- root dedup keys on isset() (existence, not value), + // so the assigned literal is immaterial; the diamond test pins single-enumeration. + $seenRoots[$rootKey] = true; + foreach ($this->finder->find($root)->filepaths as $file) { + if (str_ends_with($file, '.xphp')) { + $rootByFile[$file] = $root; + } + } + } + + foreach ($manifest->include as $entry) { + $isGlob = self::isGlob($entry); + $dirs = $isGlob ? $this->expandGlob($dir, $entry) : [self::join($dir, $entry)]; + foreach ($dirs as $candidate) { + $childManifest = $candidate . '/' . self::MANIFEST_FILENAME; + if (!is_file($childManifest)) { + if ($isGlob) { + continue; // a glob over-matches non-xphp dirs — skip those silently. + } + throw new RuntimeException(sprintf( + 'Invalid %s: include "%s" has no %s (%s).', + $manifestPath, + $entry, + self::MANIFEST_FILENAME, + $childManifest, + )); + } + $this->walk($childManifest, $this->parseFile($childManifest), $visited, $seenRoots, $rootByFile); + } + } + } + + /** Resolve $entry (a manifest file or a dir containing one) to a manifest filepath. */ + private function manifestPathFor(string $entry): string + { + if (is_file($entry)) { + return $entry; + } + if (is_dir($entry)) { + $path = $entry . '/' . self::MANIFEST_FILENAME; + if (is_file($path)) { + return $path; + } + throw new RuntimeException(sprintf('No %s found in %s.', self::MANIFEST_FILENAME, $entry)); + } + throw new RuntimeException(sprintf('Manifest path does not exist: %s.', $entry)); + } + + private function parseFile(string $manifestPath): Manifest + { + return $this->parser->parse($this->reader->read($manifestPath), $manifestPath); + } + + /** + * Expand a glob (relative to $base) to the directories it matches, via the native libc glob + * with GLOB_ONLYDIR (so only directories are returned, sorted). Single-star segments are + * supported, so composer's flat `vendor//` layout is matched by a two-segment star + * glob under `vendor`. Recursive double-star is intentionally unsupported — rejected with a + * clear message rather than silently mis-handled. + * + * @return list + */ + private function expandGlob(string $base, string $pattern): array + { + if (str_contains($pattern, '**')) { + throw new RuntimeException(sprintf( + 'Invalid include glob "%s": "**" is not supported — use "*" per path segment (e.g. "vendor/*/*").', + $pattern, + )); + } + $matches = glob(self::join($base, $pattern), GLOB_ONLYDIR); + + return $matches === false ? [] : $matches; + } + + private static function isGlob(string $s): bool + { + return strpbrk($s, '*?[') !== false; + } + + /** Join $rel onto $base unless $rel is already absolute; `.` resolves to $base. */ + private static function join(string $base, string $rel): string + { + if (str_starts_with($rel, '/')) { + return $rel; + } + if ($rel === '.' || $rel === './') { + return $base; + } + + return $base . '/' . $rel; + } + + /** realpath() for stable dedup keys, falling back to the raw path when it doesn't resolve. */ + private static function realKey(string $path): string + { + $real = realpath($path); + + return $real !== false ? $real : $path; + } +} diff --git a/src/Config/ResolvedSources.php b/src/Config/ResolvedSources.php new file mode 100644 index 0000000..75bd486 --- /dev/null +++ b/src/Config/ResolvedSources.php @@ -0,0 +1,28 @@ + $rootByFile absolute `.xphp` filepath → its source-root dir + * @param ?string $target absolute build-output dir from the entry manifest (null if unset) + * @param ?string $cache absolute generated-class cache dir from the entry manifest (null if unset) + */ + public function __construct( + public FilepathArray $files, + public array $rootByFile, + public ?string $target, + public ?string $cache, + ) { + } +} diff --git a/test/Config/ManifestResolverTest.php b/test/Config/ManifestResolverTest.php new file mode 100644 index 0000000..d57277d --- /dev/null +++ b/test/Config/ManifestResolverTest.php @@ -0,0 +1,255 @@ +work = sys_get_temp_dir() . '/xphp-manifest-' . uniqid('', true); + mkdir($this->work, 0o755, true); + } + + protected function tearDown(): void + { + self::rrmdir($this->work); + } + + public function testResolvesOwnSources(): void + { + $this->pkg('app', '{"sources":["src"]}', ['src/A.xphp', 'src/Sub/B.xphp']); + + $r = $this->resolve($this->work . '/app'); + + self::assertSame(['A.xphp', 'B.xphp'], $this->basenames($r)); + self::assertNull($r->target); + self::assertNull($r->cache); + } + + public function testOmittedSourcesDefaultsToManifestDir(): void + { + $this->pkg('app', '{}', ['Root.xphp']); + + $r = $this->resolve($this->work . '/app'); + + self::assertSame(['Root.xphp'], $this->basenames($r)); + // The root for that file is the manifest dir itself. + self::assertSame($this->work . '/app', array_values($r->rootByFile)[0]); + } + + public function testTargetAndCacheAreAbsolutisedFromEntryDir(): void + { + $this->pkg('app', '{"sources":["src"],"target":"dist","cache":".xphp-cache"}', ['src/A.xphp']); + + $r = $this->resolve($this->work . '/app'); + + self::assertSame($this->work . '/app/dist', $r->target); + self::assertSame($this->work . '/app/.xphp-cache', $r->cache); + } + + public function testCrossPackageExplicitInclude(): void + { + $this->pkg('app', '{"sources":["src"],"include":["../lib"]}', ['src/Use.xphp']); + $this->pkg('lib', '{"sources":["src"]}', ['src/Box.xphp']); + + $r = $this->resolve($this->work . '/app'); + + self::assertSame(['Box.xphp', 'Use.xphp'], $this->basenames($r)); + } + + public function testGlobDiscoverySkippingNonXphpDirs(): void + { + $this->pkg('app', '{"sources":["src"],"include":["vendor/*/*"]}', ['src/Use.xphp']); + // A plain (non-xphp) package with no manifest, named to sort FIRST among the glob matches — + // it must be skipped (not error, not short-circuit the later xphp packages). + $this->pkg('app/vendor/org/0plain', null, ['src/ignored.php']); + $this->pkg('app/vendor/org/pkga', '{"sources":["src"]}', ['src/A.xphp']); + $this->pkg('app/vendor/org/pkgb', '{"sources":["src"]}', ['src/B.xphp']); + + $r = $this->resolve($this->work . '/app'); + + self::assertSame(['A.xphp', 'B.xphp', 'Use.xphp'], $this->basenames($r)); + } + + public function testGlobMatchingNoDirsIsACleanNoOp(): void + { + // A vendor glob that matches nothing (no packages installed yet) is not an error. + $this->pkg('app', '{"sources":["src"],"include":["vendor/*/*"]}', ['src/Use.xphp']); + + $r = $this->resolve($this->work . '/app'); + + self::assertSame(['Use.xphp'], $this->basenames($r)); + } + + public function testAbsolutePathIncludeIsResolved(): void + { + $this->pkg('lib', '{"sources":["src"]}', ['src/Box.xphp']); + // An absolute include path is used as-is (not joined onto the manifest dir). + $this->pkg('app', sprintf('{"sources":["src"],"include":["%s"]}', $this->work . '/lib'), ['src/Use.xphp']); + + $r = $this->resolve($this->work . '/app'); + + self::assertSame(['Box.xphp', 'Use.xphp'], $this->basenames($r)); + } + + public function testDoubleStarGlobIsRejected(): void + { + $this->pkg('app', '{"include":["packages/**"]}', []); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('"**" is not supported'); + $this->resolve($this->work . '/app'); + } + + public function testExplicitIncludeWithoutManifestIsHardError(): void + { + $this->pkg('app', '{"include":["../nope"]}', []); + mkdir($this->work . '/nope', 0o755, true); // exists, but no xphp.json + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('has no xphp.json'); + $this->resolve($this->work . '/app'); + } + + public function testTransitiveAcrossThreePackages(): void + { + $this->pkg('a', '{"sources":["src"],"include":["../b"]}', ['src/A.xphp']); + $this->pkg('b', '{"sources":["src"],"include":["../c"]}', ['src/B.xphp']); + $this->pkg('c', '{"sources":["src"]}', ['src/C.xphp']); + + $r = $this->resolve($this->work . '/a'); + + self::assertSame(['A.xphp', 'B.xphp', 'C.xphp'], $this->basenames($r)); + } + + public function testDiamondResolvesSharedDependencyOnce(): void + { + $this->pkg('a', '{"include":["../b","../c"]}', []); + $this->pkg('b', '{"sources":["src"],"include":["../d"]}', ['src/B.xphp']); + $this->pkg('c', '{"sources":["src"],"include":["../d"]}', ['src/C.xphp']); + $this->pkg('d', '{"sources":["src"]}', ['src/D.xphp']); + + $r = $this->resolve($this->work . '/a'); + + // D appears exactly once despite two include paths. + self::assertSame(1, count(array_filter($this->basenames($r), static fn (string $b): bool => $b === 'D.xphp'))); + self::assertSame(['B.xphp', 'C.xphp', 'D.xphp'], $this->basenames($r)); + } + + public function testCycleTerminates(): void + { + $this->pkg('a', '{"sources":["src"],"include":["../b"]}', ['src/A.xphp']); + $this->pkg('b', '{"sources":["src"],"include":["../a"]}', ['src/B.xphp']); + + $r = $this->resolve($this->work . '/a'); // must not hang + + self::assertSame(['A.xphp', 'B.xphp'], $this->basenames($r)); + } + + public function testSelfIncludeViaSeparateEntryManifest(): void + { + // Dev-profile idiom: a separate entry manifest pulls the package's own xphp.json (its `src`) + // via include ["."], and adds `tests`. + mkdir($this->work . '/app/src', 0o755, true); + mkdir($this->work . '/app/tests', 0o755, true); + file_put_contents($this->work . '/app/xphp.json', '{"sources":["src"]}'); + file_put_contents($this->work . '/app/xphp.dev.json', '{"sources":["tests"],"include":["."]}'); + file_put_contents($this->work . '/app/src/Lib.xphp', 'work . '/app/tests/LibTest.xphp', 'resolve($this->work . '/app/xphp.dev.json'); + + self::assertSame(['Lib.xphp', 'LibTest.xphp'], $this->basenames($r)); + } + + public function testMissingSourceDirIsHardError(): void + { + $this->pkg('app', '{"sources":["nope"]}', []); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('is not a directory'); + $this->resolve($this->work . '/app'); + } + + public function testNoManifestInDirIsHardError(): void + { + mkdir($this->work . '/empty', 0o755, true); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No xphp.json found'); + $this->resolve($this->work . '/empty'); + } + + public function testNonexistentEntryPathIsHardError(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('does not exist'); + $this->resolve($this->work . '/ghost'); + } + + public function testEntryMayBeAnExplicitManifestFile(): void + { + $this->pkg('app', '{"sources":["src"]}', ['src/A.xphp']); + + $r = $this->resolve($this->work . '/app/xphp.json'); + + self::assertSame(['A.xphp'], $this->basenames($r)); + } + + // --- helpers --- + + /** @param array $files relative path (or content-keyed) => content */ + private function pkg(string $rel, ?string $manifestJson, array $files): void + { + $dir = $this->work . '/' . $rel; + mkdir($dir, 0o755, true); + if ($manifestJson !== null) { + file_put_contents($dir . '/xphp.json', $manifestJson); + } + foreach ($files as $f) { + $path = $dir . '/' . $f; + if (!is_dir(dirname($path))) { + mkdir(dirname($path), 0o755, true); + } + file_put_contents($path, 'resolve($entry); + } + + /** @return list sorted basenames of the resolved files */ + private function basenames(ResolvedSources $r): array + { + $names = array_map(static fn (string $p): string => basename($p), $r->files->filepaths); + sort($names); + + return array_values($names); + } + + private static function rrmdir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) ?: [] as $e) { + if ($e === '.' || $e === '..') { + continue; + } + $p = $dir . '/' . $e; + is_dir($p) ? self::rrmdir($p) : unlink($p); + } + rmdir($dir); + } +} From db324f75625c2207bcac51d285d20e10a54c55fd Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 22 Jun 2026 07:13:39 +0000 Subject: [PATCH 066/114] feat(cli): resolve compile/check sources from an xphp.json manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both commands now accept their sources from either the existing positional source directory (back-compatible) or an `xphp.json` manifest — via `--config ` or an auto-detected `xphp.json` in the working directory. A new `SourceResolver` encapsulates the precedence (positional → --config → auto-detect) and returns a `ResolvedSources` (files + per-file root map + optional target/cache), which threads the root map into `compile` (root-aware emit) and the check PHPStan pass. `compile` output dirs resolve as: --target/--cache option > manifest value > positional arg > default, branched by mode so the manifest and positional fallbacks (which never coexist) stay independent. The `source` argument becomes optional; the "Source directory not found" failure and exit codes are preserved. This makes a package self-describe its sources and pull in others (e.g. `"include": ["vendor/*/*"]`), compiling the union in one pass — no staging step. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Config/SourceResolver.php | 74 ++++++ src/Console/ApplicationConsole.php | 8 +- src/Console/Command/CheckCommand.php | 44 ++-- src/Console/Command/CompileCommand.php | 81 ++++-- test/Console/CheckCommandPhpStanTest.php | 11 +- test/Console/CheckCommandTest.php | 43 +++- test/Console/CompileCommandTest.php | 298 +++++++++++++++++++++++ 7 files changed, 518 insertions(+), 41 deletions(-) create mode 100644 src/Config/SourceResolver.php create mode 100644 test/Console/CompileCommandTest.php diff --git a/src/Config/SourceResolver.php b/src/Config/SourceResolver.php new file mode 100644 index 0000000..fbecec0 --- /dev/null +++ b/src/Config/SourceResolver.php @@ -0,0 +1,74 @@ +fromDirectory($sourceDir); + } + + $manifest = $config ?? $this->autodetect($workingDir); + if ($manifest === null) { + throw new RuntimeException( + 'No sources to compile: pass a source directory, --config , or add an xphp.json to the working directory.', + ); + } + + return $this->manifestResolver->resolve($manifest); + } + + private function fromDirectory(string $dir): ResolvedSources + { + $files = $this->finder->find($dir) + ->filter(static fn (string $filepath): bool => str_ends_with($filepath, '.xphp')); + + $rootByFile = []; + // @infection-ignore-all -- in single-dir mode this map just mirrors `$dir`, which is also + // Compiler::compile's scalar-base fallback, so an empty map emits to the identical paths; + // the map is load-bearing only for manifest multi-root (covered in CompileCommandTest). + foreach ($files->filepaths as $filepath) { + $rootByFile[$filepath] = $dir; + } + + return new ResolvedSources($files, $rootByFile, null, null); + } + + private function autodetect(string $workingDir): ?string + { + $candidate = $workingDir . '/' . ManifestResolver::MANIFEST_FILENAME; + + return is_file($candidate) ? $candidate : null; + } +} diff --git a/src/Console/ApplicationConsole.php b/src/Console/ApplicationConsole.php index 901a09d..7554aeb 100644 --- a/src/Console/ApplicationConsole.php +++ b/src/Console/ApplicationConsole.php @@ -7,6 +7,8 @@ use PhpParser\ParserFactory; use PhpParser\PrettyPrinter\Standard as StandardPrinter; use Symfony\Component\Console\Application; +use XPHP\Config\ManifestResolver; +use XPHP\Config\SourceResolver; use XPHP\Console\Command\CheckCommand; use XPHP\Console\Command\CompileCommand; use XPHP\FileSystem\FileFinder; @@ -47,7 +49,9 @@ public function __construct( $hashLength, ); - $this->addCommand(new CompileCommand($fileFinder, $compiler)); - $this->addCommand(new CheckCommand($fileFinder, $compiler, new StaticAnalysisGate($compiler))); + $sourceResolver = new SourceResolver($fileFinder, new ManifestResolver($fileReader, $fileFinder)); + + $this->addCommand(new CompileCommand($sourceResolver, $compiler)); + $this->addCommand(new CheckCommand($sourceResolver, $compiler, new StaticAnalysisGate($compiler))); } } diff --git a/src/Console/Command/CheckCommand.php b/src/Console/Command/CheckCommand.php index 7030f9b..bab3320 100644 --- a/src/Console/Command/CheckCommand.php +++ b/src/Console/Command/CheckCommand.php @@ -10,11 +10,12 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use RuntimeException; +use XPHP\Config\SourceResolver; use XPHP\Diagnostics\Renderer\DiagnosticRenderer; use XPHP\Diagnostics\Renderer\GithubRenderer; use XPHP\Diagnostics\Renderer\JsonRenderer; use XPHP\Diagnostics\Renderer\TextRenderer; -use XPHP\FileSystem\FileFinder; use XPHP\StaticAnalysis\StaticAnalysisGate; use XPHP\Transpiler\Monomorphize\Compiler; @@ -34,7 +35,7 @@ final class CheckCommand extends Command { public function __construct( - private readonly FileFinder $fileFinder, + private readonly SourceResolver $sourceResolver, private readonly Compiler $compiler, private readonly StaticAnalysisGate $staticAnalysisGate, ) { @@ -44,7 +45,8 @@ public function __construct( protected function configure(): void { $this - ->addArgument('source', InputArgument::REQUIRED, 'Directory containing .xphp source files') + ->addArgument('source', InputArgument::OPTIONAL, 'Directory containing .xphp source files (omit when using --config or an auto-detected xphp.json)') + ->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Path to an xphp.json manifest (or a dir containing one)') ->addOption('format', null, InputOption::VALUE_REQUIRED, 'Output format: text, json, or github', 'text') ->addOption('no-phpstan', null, InputOption::VALUE_NONE, 'Skip the PHPStan pass over the compiled output') ->addOption('phpstan-bin', null, InputOption::VALUE_REQUIRED, 'Path to the PHPStan binary (default: vendor/bin/phpstan, then $PATH)') @@ -55,15 +57,10 @@ protected function execute( InputInterface $input, OutputInterface $output, ): int { - // getArgument()/getOption() are typed `mixed`; these are scalar inputs (a required - // argument and an option with a string default), so they are always strings — narrow - // rather than blind-cast (PHPStan level 9 rejects casting mixed). + // getArgument()/getOption() are typed `mixed`; narrow rather than blind-cast (PHPStan + // level 9 rejects casting mixed). $sourceArg = $input->getArgument('source'); - $sourceDir = is_string($sourceArg) ? $sourceArg : ''; - if (!is_dir($sourceDir)) { - $output->writeln("Source directory not found: {$sourceDir}"); - return self::INVALID; - } + $configOpt = $input->getOption('config'); $formatOption = $input->getOption('format'); $renderer = $this->rendererFor(is_string($formatOption) ? $formatOption : ''); @@ -72,10 +69,22 @@ protected function execute( return self::INVALID; } - $sources = $this->fileFinder - ->find($sourceDir) - ->filter(static fn (string $filepath): bool => str_ends_with($filepath, '.xphp')); + // @infection-ignore-all -- getcwd() is effectively always a string; the `?: '.'` fallback + // resolves identically to the live cwd for auto-detection and PHPStan config lookup. + $cwd = getcwd() ?: '.'; + try { + $resolved = $this->sourceResolver->resolve( + is_string($sourceArg) ? $sourceArg : null, + is_string($configOpt) ? $configOpt : null, + $cwd, + ); + } catch (RuntimeException $e) { + // @infection-ignore-all -- the banner is decoration; message content is asserted. + $output->writeln('' . $e->getMessage() . ''); + return self::INVALID; + } + $sources = $resolved->files; $diagnostics = $this->compiler->check($sources); // Only layer PHPStan on when the generic checks pass: invalid generics can't be @@ -86,10 +95,13 @@ protected function execute( $configOption = $input->getOption('phpstan-config'); $findings = $this->staticAnalysisGate->analyze( $sources, - $sourceDir, - getcwd() ?: '.', + // @infection-ignore-all -- rootByFile is authoritative for the temp-workspace emit; + // this scalar base is an unused fallback, and PHPStan resolves by symbol not path. + is_string($sourceArg) ? $sourceArg : '', + $cwd, is_string($binOption) ? $binOption : null, is_string($configOption) ? $configOption : null, + $resolved->rootByFile, ); foreach ($findings as $finding) { $diagnostics->add($finding); diff --git a/src/Console/Command/CompileCommand.php b/src/Console/Command/CompileCommand.php index 366bb5f..a775e9e 100644 --- a/src/Console/Command/CompileCommand.php +++ b/src/Console/Command/CompileCommand.php @@ -4,55 +4,89 @@ namespace XPHP\Console\Command; +use RuntimeException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use XPHP\FileSystem\FileFinder; +use XPHP\Config\SourceResolver; use XPHP\Transpiler\Monomorphize\Compiler; +/** + * `xphp compile [ [ []]] [--config=PATH] [--target=DIR] [--cache=DIR]` + * + * Single-dir form (back-compatible): `xphp compile src dist cache`. Manifest form: omit the source + * and supply `--config ` (or run where an `xphp.json` is auto-detected) to compile a + * package together with its declared sources and transitively-included packages in one pass. + * Output dirs resolve as: `--target`/`--cache` option > manifest value > positional arg > default. + */ #[AsCommand('compile')] final class CompileCommand extends Command { public function __construct( - private readonly FileFinder $fileFinder, + private readonly SourceResolver $sourceResolver, private readonly Compiler $compiler, ) { parent::__construct(); } - public function configure(): void + protected function configure(): void { $this - ->addArgument('source', InputArgument::REQUIRED, 'Directory containing .xphp source files') - ->addArgument('target', InputArgument::OPTIONAL, 'Directory to emit rewritten .php files', 'dist') - ->addArgument('cache', InputArgument::OPTIONAL, 'Directory for generated specialized classes', '.xphp-cache'); + ->addArgument('source', InputArgument::OPTIONAL, 'Directory containing .xphp source files (omit when using --config or an auto-detected xphp.json)') + ->addArgument('target', InputArgument::OPTIONAL, 'Directory to emit rewritten .php files (default: dist)') + ->addArgument('cache', InputArgument::OPTIONAL, 'Directory for generated specialized classes (default: .xphp-cache)') + ->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Path to an xphp.json manifest (or a dir containing one)') + ->addOption('target', null, InputOption::VALUE_REQUIRED, 'Emit dir (overrides the manifest and positional arg)') + ->addOption('cache', null, InputOption::VALUE_REQUIRED, 'Cache dir (overrides the manifest and positional arg)'); } - public function execute( + protected function execute( InputInterface $input, OutputInterface $output, ): int { - // getArgument() is typed `mixed`; these are scalar args (a required one and two with - // string defaults), so they are always strings — narrow rather than blind-cast. - $sourceArg = $input->getArgument('source'); - $targetArg = $input->getArgument('target'); - $cacheArg = $input->getArgument('cache'); - $sourceDir = is_string($sourceArg) ? $sourceArg : ''; - $targetDir = is_string($targetArg) ? $targetArg : 'dist'; - $cacheDir = is_string($cacheArg) ? $cacheArg : '.xphp-cache'; + $sourceArg = self::stringOrNull($input->getArgument('source')); + $configOpt = self::stringOrNull($input->getOption('config')); - if (!is_dir($sourceDir)) { - $output->writeln("Source directory not found: {$sourceDir}"); + // @infection-ignore-all -- getcwd() is effectively always a string under any run; the + // `?: '.'` fallback resolves identically to the live cwd for auto-detection. + $cwd = getcwd() ?: '.'; + try { + $resolved = $this->sourceResolver->resolve($sourceArg, $configOpt, $cwd); + } catch (RuntimeException $e) { + // @infection-ignore-all -- the banner is decoration; the message content is + // what's asserted, so reordering/removing the wrapping tags is behaviourally immaterial. + $output->writeln('' . $e->getMessage() . ''); return self::FAILURE; } - $sources = $this->fileFinder - ->find($sourceDir) - ->filter(static fn (string $filepath): bool => str_ends_with($filepath, '.xphp')); + // Output dirs: option > (manifest value | positional arg) > default. The manifest value and + // the positional arg never coexist (positional target/cache require a positional source, + // which manifest mode lacks), so each mode picks its own fallback — keeping every step + // reachable rather than chaining a dead manifest-vs-positional link. + $targetOpt = self::stringOrNull($input->getOption('target')); + $cacheOpt = self::stringOrNull($input->getOption('cache')); + if ($sourceArg !== null) { + $target = $targetOpt ?? self::stringOrNull($input->getArgument('target')) ?? 'dist'; + $cache = $cacheOpt ?? self::stringOrNull($input->getArgument('cache')) ?? '.xphp-cache'; + } else { + $target = $targetOpt ?? $resolved->target ?? 'dist'; + $cache = $cacheOpt ?? $resolved->cache ?? '.xphp-cache'; + } - $result = $this->compiler->compile($sources, $sourceDir, $targetDir, $cacheDir); + // `$resolved->rootByFile` is authoritative for emit paths; the scalar base is only a + // fallback for any unmapped file (none here), so the source arg (or empty) suffices. + // @infection-ignore-all -- rootByFile covers every file, so the scalar base is never read. + $base = $sourceArg ?? ''; + $result = $this->compiler->compile( + $resolved->files, + $base, + $target, + $cache, + $resolved->rootByFile, + ); $output->writeln(sprintf( 'Compiled %d source file(s); generated %d specialized class(es).', @@ -62,4 +96,9 @@ public function execute( return self::SUCCESS; } + + private static function stringOrNull(mixed $value): ?string + { + return is_string($value) && $value !== '' ? $value : null; + } } diff --git a/test/Console/CheckCommandPhpStanTest.php b/test/Console/CheckCommandPhpStanTest.php index 9bac291..cc9c346 100644 --- a/test/Console/CheckCommandPhpStanTest.php +++ b/test/Console/CheckCommandPhpStanTest.php @@ -10,6 +10,8 @@ use PHPUnit\Framework\TestCase; use RuntimeException; use Symfony\Component\Console\Tester\CommandTester; +use XPHP\Config\ManifestResolver; +use XPHP\Config\SourceResolver; use XPHP\FileSystem\FileFinder\NativeFileFinder; use XPHP\FileSystem\FileReader\NativeFileReader; use XPHP\FileSystem\FileWriter\NativeFileWriter; @@ -156,7 +158,14 @@ private function tester(): CommandTester ); return new CommandTester( - new CheckCommand(new NativeFileFinder(), $compiler, new StaticAnalysisGate($compiler)), + new CheckCommand( + new SourceResolver( + new NativeFileFinder(), + new ManifestResolver(new NativeFileReader(), new NativeFileFinder()), + ), + $compiler, + new StaticAnalysisGate($compiler), + ), ); } diff --git a/test/Console/CheckCommandTest.php b/test/Console/CheckCommandTest.php index 304c3ad..0a5b3a2 100644 --- a/test/Console/CheckCommandTest.php +++ b/test/Console/CheckCommandTest.php @@ -9,6 +9,8 @@ use PHPUnit\Framework\TestCase; use RuntimeException; use Symfony\Component\Console\Tester\CommandTester; +use XPHP\Config\ManifestResolver; +use XPHP\Config\SourceResolver; use XPHP\FileSystem\FileFinder\NativeFileFinder; use XPHP\FileSystem\FileReader\NativeFileReader; use XPHP\FileSystem\FileWriter\NativeFileWriter; @@ -31,6 +33,25 @@ public function testCleanSourcesExitZero(): void self::assertStringContainsString('No problems found', $tester->getDisplay()); } + public function testResolvesSourcesFromConfigManifest(): void + { + // `check --config ` resolves sources from the manifest (no positional source). + $dir = sys_get_temp_dir() . '/xphp-check-cfg-' . uniqid('', true); + mkdir($dir . '/src', 0o755, true); + file_put_contents($dir . '/xphp.json', '{"sources":["src"]}'); + file_put_contents($dir . '/src/Box.xphp', " { public function get(): T { throw new \\LogicException; } }\n"); + + try { + $tester = $this->tester(); + $exit = $tester->execute(['--config' => $dir . '/xphp.json', '--no-phpstan' => true]); + + self::assertSame(0, $exit); + self::assertStringContainsString('No problems found', $tester->getDisplay()); + } finally { + self::rrmdir($dir); + } + } + public function testGenericErrorsExitOne(): void { $tester = $this->tester(); @@ -104,8 +125,13 @@ private function tester(): CommandTester $printer, ); + $sourceResolver = new SourceResolver( + new NativeFileFinder(), + new ManifestResolver(new NativeFileReader(), new NativeFileFinder()), + ); + return new CommandTester( - new CheckCommand(new NativeFileFinder(), $compiler, new StaticAnalysisGate($compiler)), + new CheckCommand($sourceResolver, $compiler, new StaticAnalysisGate($compiler)), ); } @@ -114,4 +140,19 @@ private function fixtureDir(string $fixture): string return realpath(__DIR__ . '/../fixture/check/' . $fixture . '/source') ?: throw new RuntimeException("Fixture missing: {$fixture}"); } + + private static function rrmdir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . '/' . $entry; + is_dir($path) ? self::rrmdir($path) : unlink($path); + } + rmdir($dir); + } } diff --git a/test/Console/CompileCommandTest.php b/test/Console/CompileCommandTest.php new file mode 100644 index 0000000..2901dc7 --- /dev/null +++ b/test/Console/CompileCommandTest.php @@ -0,0 +1,298 @@ +work = sys_get_temp_dir() . '/xphp-compile-cmd-' . uniqid('', true); + mkdir($this->work, 0o755, true); + } + + protected function tearDown(): void + { + self::rrmdir($this->work); + } + + public function testSingleDirPositionalFormStillWorks(): void + { + mkdir($this->work . '/src', 0o755, true); + file_put_contents($this->work . '/src/Plain.xphp', "tester(); + $exit = $tester->execute([ + 'source' => $this->work . '/src', + 'target' => $this->work . '/dist', + 'cache' => $this->work . '/cache', + ]); + + self::assertSame(Command::SUCCESS, $exit); + self::assertStringContainsString('Compiled', $tester->getDisplay()); + self::assertFileExists($this->work . '/dist/Plain.php'); + // positional cache arg honoured (registry lands there). + self::assertFileExists($this->work . '/cache/registry.json'); + } + + public function testSingleDirTargetCacheOptionsOverridePositional(): void + { + mkdir($this->work . '/src', 0o755, true); + file_put_contents($this->work . '/src/Plain.xphp', "tester()->execute([ + 'source' => $this->work . '/src', + 'target' => $this->work . '/posdist', + 'cache' => $this->work . '/poscache', + '--target' => $this->work . '/optdist', + '--cache' => $this->work . '/optcache', + ]); + + self::assertSame(Command::SUCCESS, $exit); + self::assertFileExists($this->work . '/optdist/Plain.php', '--target option wins over positional'); + self::assertFileDoesNotExist($this->work . '/posdist/Plain.php'); + self::assertFileExists($this->work . '/optcache/registry.json', '--cache option wins over positional'); + self::assertFileDoesNotExist($this->work . '/poscache/registry.json'); + } + + public function testCrossPackageConsumeViaConfigEmitsUpstreamAndRunsWithoutFatal(): void + { + // Upstream ships a generic Box as .xphp source + its manifest. + $this->writePackage('lib', '{"sources":["src"]}', [ + 'src/Box.xphp' => " { public function __construct(public T \$v) {} }\n", + ]); + // Downstream includes it and instantiates Box::, with build dirs in the manifest. + $this->writePackage('app', '{"sources":["src"],"include":["../lib"],"target":"dist","cache":"cache"}', [ + 'src/Use.xphp' => "(1);\n", + ]); + + $exit = $this->tester()->execute(['--config' => $this->work . '/app']); + self::assertSame(Command::SUCCESS, $exit); + + // Emit-all: the UPSTREAM marker is emitted into the consumer's output (not skipped) ... + self::assertFileExists($this->work . '/app/dist/Box.php', 'upstream Box marker must be emitted'); + self::assertFileExists($this->work . '/app/dist/Use.php'); + // ... and the consumer-driven specialization exists in the cache. + $specs = glob($this->work . '/app/cache/Generated/App/Box/T_*.php') ?: []; + self::assertCount(1, $specs, 'one Box specialization'); + + // And the compiled output loads + runs with NO "interface not found" / fatal. + self::assertSame('OK', $this->runCompiledOutput($this->work . '/app/dist', $this->work . '/app/cache')); + } + + public function testGlobDiscoveryViaConfig(): void + { + $this->writePackage('app', '{"sources":["src"],"include":["vendor/*/*"]}', [ + 'src/Use.xphp' => "writePackage('app/vendor/org/pkg', '{"sources":["src"]}', [ + 'src/Dep.xphp' => "work . '/app/vendor/org/plain', 0o755, true); + + $exit = $this->tester()->execute(['--config' => $this->work . '/app', '--target' => $this->work . '/out', '--cache' => $this->work . '/c']); + + self::assertSame(Command::SUCCESS, $exit); + self::assertFileExists($this->work . '/out/Use.php'); + self::assertFileExists($this->work . '/out/Dep.php', 'discovered vendor package compiled'); + } + + public function testManifestTargetCacheUsedAndOptionOverrides(): void + { + // Non-default dir names ("build"/"artifacts") so the precedence is distinguishable from the + // 'dist'/'.xphp-cache' fallbacks. + $this->writePackage('app', '{"sources":["src"],"target":"build","cache":"artifacts"}', [ + 'src/A.xphp' => "tester()->execute(['--config' => $this->work . '/app']); + self::assertFileExists($this->work . '/app/build/A.php'); + self::assertFileExists($this->work . '/app/artifacts/registry.json'); + self::assertFileDoesNotExist($this->work . '/app/dist/A.php'); + + // --target / --cache options override the manifest values. + $this->tester()->execute([ + '--config' => $this->work . '/app', + '--target' => $this->work . '/over-dist', + '--cache' => $this->work . '/over-cache', + ]); + self::assertFileExists($this->work . '/over-dist/A.php'); + self::assertFileExists($this->work . '/over-cache/registry.json'); + } + + public function testAutodetectsXphpJsonInWorkingDirectory(): void + { + $this->writePackage('app', '{"sources":["src"],"target":"dist","cache":"cache"}', [ + 'src/A.xphp' => "work . '/app'); + try { + $exit = $this->tester()->execute([]); // no source, no --config → auto-detect cwd/xphp.json + } finally { + chdir($prev !== false ? $prev : '/'); + } + + self::assertSame(Command::SUCCESS, $exit); + self::assertFileExists($this->work . '/app/dist/A.php'); + } + + public function testConfigOptionTakesPrecedenceOverAutodetectedManifest(): void + { + // cwd holds an xphp.json (the auto-detect candidate) → srcA; --config points elsewhere → srcB. + $this->writePackage('app', json_encode(['sources' => ['srcA'], 'target' => $this->work . '/distA', 'cache' => $this->work . '/cacheA']) ?: '{}', [ + 'srcA/A.xphp' => " "work . '/app/other.json', + json_encode(['sources' => ['srcB'], 'target' => $this->work . '/distB', 'cache' => $this->work . '/cacheB']) ?: '{}', + ); + + $prev = getcwd(); + chdir($this->work . '/app'); + try { + $exit = $this->tester()->execute(['--config' => $this->work . '/app/other.json']); + } finally { + chdir($prev !== false ? $prev : '/'); + } + + self::assertSame(Command::SUCCESS, $exit); + self::assertFileExists($this->work . '/distB/B.php', '--config manifest is used'); + self::assertFileDoesNotExist($this->work . '/distA/A.php', 'auto-detected manifest is NOT used when --config is given'); + } + + public function testEmptyTargetOptionFallsThroughToManifest(): void + { + // An empty --target is treated as absent (stringOrNull), so the manifest value wins — + // pins that `stringOrNull` requires BOTH is_string AND non-empty. + $this->writePackage('app', '{"sources":["src"],"target":"build","cache":"artifacts"}', [ + 'src/A.xphp' => "tester()->execute(['--config' => $this->work . '/app', '--target' => '']); + + self::assertSame(Command::SUCCESS, $exit); + self::assertFileExists($this->work . '/app/build/A.php', 'empty --target ignored; manifest target used'); + } + + public function testNoSourceProviderIsAClearFailure(): void + { + $tester = $this->tester(); + $exit = $tester->execute([]); + + self::assertSame(Command::FAILURE, $exit); + self::assertStringContainsString('No sources to compile', $tester->getDisplay()); + } + + public function testMissingSingleDirIsAClearFailure(): void + { + $tester = $this->tester(); + $exit = $tester->execute(['source' => $this->work . '/ghost']); + + self::assertSame(Command::FAILURE, $exit); + self::assertStringContainsString('Source directory not found', $tester->getDisplay()); + } + + // --- helpers --- + + private function tester(): CommandTester + { + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + $compiler = new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + $sourceResolver = new SourceResolver( + new NativeFileFinder(), + new ManifestResolver(new NativeFileReader(), new NativeFileFinder()), + ); + + return new CommandTester(new CompileCommand($sourceResolver, $compiler)); + } + + /** @param array $files relative path => content */ + private function writePackage(string $rel, string $manifest, array $files): void + { + $dir = $this->work . '/' . $rel; + mkdir($dir, 0o755, true); + file_put_contents($dir . '/xphp.json', $manifest); + foreach ($files as $path => $content) { + $full = $dir . '/' . $path; + if (!is_dir(dirname($full))) { + mkdir(dirname($full), 0o755, true); + } + file_put_contents($full, $content); + } + } + + /** Load the compiled output (target + generated cache) in a subprocess and require it; "OK" on no fatal. */ + private function runCompiledOutput(string $target, string $cache): string + { + $loader = $this->work . '/load.php'; + $prefixes = [ + 'XPHP\\Generated\\' => $cache . '/Generated', + 'App\\' => $target, + ]; + $script = " $base) {' . "\n" + . ' if (str_starts_with($c, $p)) {' . "\n" + . ' $f = $base . "/" . str_replace("\\\\", "/", substr($c, strlen($p))) . ".php";' . "\n" + . ' if (is_file($f)) { require_once $f; }' . "\n" + . ' }' . "\n" + . ' }' . "\n" + . "});\n" + . 'require ' . var_export($target . '/Use.php', true) . ";\n" + . "echo \"OK\\n\";\n"; + file_put_contents($loader, $script); + + $out = []; + $code = 0; + exec('php ' . escapeshellarg($loader) . ' 2>&1', $out, $code); + + return $code === 0 && in_array('OK', $out, true) ? 'OK' : implode("\n", $out); + } + + private static function rrmdir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) ?: [] as $e) { + if ($e === '.' || $e === '..') { + continue; + } + $p = $dir . '/' . $e; + is_dir($p) ? self::rrmdir($p) : unlink($p); + } + rmdir($dir); + } +} From 9ac12e80d80a10981628b3359ea6d5955502e10d Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 22 Jun 2026 07:16:06 +0000 Subject: [PATCH 067/114] docs: document the xphp.json manifest + record ADR-0017 Add a "Compiling a package and its dependencies" section to getting-started covering the xphp.json schema (sources, include globs with vendor auto-discovery, target/cache), the --config / auto-detect CLI surface, and the ship-sources / compile-downstream distribution model. Record the decision as ADR-0017 (config-manifest multi-root resolution: plain JSON / no dep, emit-all so the consumer is the sole compiler, glob auto-discovery, root-aware emit; prebuilt distribution deferred). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../0017-config-manifest-source-resolution.md | 85 +++++++++++++++++++ docs/adr/README.md | 1 + docs/getting-started.md | 42 +++++++++ 3 files changed, 128 insertions(+) create mode 100644 docs/adr/0017-config-manifest-source-resolution.md diff --git a/docs/adr/0017-config-manifest-source-resolution.md b/docs/adr/0017-config-manifest-source-resolution.md new file mode 100644 index 0000000..e338965 --- /dev/null +++ b/docs/adr/0017-config-manifest-source-resolution.md @@ -0,0 +1,85 @@ +# 17. Config-manifest, multi-root source resolution (`xphp.json`) + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +`xphp compile`/`check` took a single source directory. Because specialization needs each generic +template's parsed AST in memory, a `new Foo::()` call site can only be specialized when +`Foo`'s `.xphp` template is in the *same* compiled source set ([ADR-0001](0001-monomorphization-over-type-erasure.md)). +So a package that ships `.xphp` templates, and any app consuming one, had to **stage** the +templates and the consumer tree into a single directory on every build (a copy step). Consuming +another package's generics is a first-class goal, and that friction defeats it. + +We need a way to compile several source roots together in one invocation, and to do so without the +developer hand-listing every dependency or re-editing config when a package is installed. + +## Decision Drivers + +- Let a package self-describe its `.xphp` sources, and pull in other packages, in one compile. +- Auto-discover dependencies — installing a new xphp package must not require a manifest edit. +- Keep xphp's minimal-dependency posture and its pure-transpiler runtime model. +- Preserve the existing single-directory CLI form unchanged. + +## Considered Options + +- **Repeatable `--source` flags** — multiple roots on the command line. Works, but the consumer must + enumerate every (transitive) dependency by hand and re-do it on every install. +- **Prebuilt/persisted registry** — ship a compiled, reloadable package and compile against it + without its sources. Larger change (serialize template ASTs + a load path); deferred (see below). +- **A config manifest (`xphp.json`)** that declares sources and transitively includes other + packages, with glob auto-discovery. + +## Decision Outcome + +Chosen: **an `xphp.json` manifest resolved into a multi-root source set.** A manifest declares +`sources` (its own `.xphp` roots) and `include` (other packages, transitively). `include` entries +may be globs; a glob-matched directory with its own `xphp.json` is pulled in, others skipped, so +`"include": ["vendor/*/*"]` discovers every installed xphp package and is set once. Resolution +dedups by realpath (diamonds resolve once; cycles terminate). The CLI takes `--config ` +or auto-detects `xphp.json` in the working directory; the single-directory positional form is +unchanged (precedence: positional source → `--config` → auto-detect). + +Three sub-decisions: + +- **Plain JSON, no new dependency.** Parsed with the built-in `json_decode`. JSON5 (comments / + trailing commas) would need a runtime parser; not worth a dependency for v1. +- **Emit-all (consumer compiles the union).** Upstreams ship `.xphp` *sources*; the downstream + build pulls and compiles them, so it is the sole compiler and **emits everything it depends on** — + the upstream's marker interfaces ([ADR-0004](0004-marker-interfaces-for-instanceof.md)) and + non-generic classes, plus the consumer-driven specializations. A "resolve-only, don't re-emit + deps" policy would leave the generated specializations referencing a marker nobody emitted + (runtime fatal); it is only correct once upstreams ship *prebuilt* PHP, which is the deferred + Option B below. +- **Root-aware emit.** `Compiler::compile` gained a per-file source-root map so each emitted file + keeps its own PSR-4 layout instead of flattening a second root to `basename()`; a same-target + collision across roots is a hard error. + +The resolution logic lives in two small services (`ManifestParser`, `ManifestResolver`) plus a +`SourceResolver` that the commands share; the monomorphization core is otherwise untouched. + +### Consequences + +- Good: a library compiles its `src` + `tests` + `examples` together (no staging script), and an + app compiles against its dependencies' templates with a one-line, install-stable `include` glob. +- Good: no new runtime dependency; xphp stays a pure transpiler (it emits, it doesn't ship a runtime). +- Trade-off: the downstream recompiles included sources on each build ("compile when needed"); a + prebuilt/incremental path is future work (Option B). +- Trade-off: the manifest is plain JSON — no comments/trailing commas. + +### Confirmation + +`ManifestParser`/`ManifestResolver` are unit-tested (transitive resolution, realpath dedup, cycle +termination, glob discovery skipping non-xphp dirs, explicit-missing-manifest error). `CompileCommand` +is end-to-end tested incl. a cross-package consume whose compiled output is required in a subprocess +and runs with no fatal (the upstream marker is emitted), glob auto-discovery, auto-detect, `--config` +precedence, and target/cache precedence. The single-directory form's behaviour is pinned unchanged. +Documented in [getting started](../getting-started.md). + +## More Information + +- [ADR-0001](0001-monomorphization-over-type-erasure.md) — specializations need the template AST. +- [ADR-0004](0004-marker-interfaces-for-instanceof.md) — the marker interfaces the consumer must emit. +- [ADR-0011](0011-phar-distribution.md) — why a new runtime dependency was avoided. +- Future: a prebuilt/persisted-registry distribution (serialize template ASTs + a load path), + enabling resolve-only deps and skip-unchanged incremental builds. diff --git a/docs/adr/README.md b/docs/adr/README.md index 193aabd..1c093a2 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -34,3 +34,4 @@ should be added here as a new numbered file; copy | [0014](0014-variance-markers-are-class-level-only.md) | Variance markers are class-level only | Accepted | | [0015](0015-variance-markers-on-private-properties.md) | Variance markers on private properties | Accepted | | [0016](0016-no-special-cased-value-equality-bound.md) | No special-cased value-equality bound (use ordinary generics) | Accepted | +| [0017](0017-config-manifest-source-resolution.md) | Config-manifest, multi-root source resolution (`xphp.json`) | Accepted | diff --git a/docs/getting-started.md b/docs/getting-started.md index 4c9fcab..dc6b5e6 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -113,6 +113,48 @@ After the compile completes you'll have: Both `dist/` and `.xphp-cache/` can be gitignored — they're generated artifacts your CI/CD pipeline rebuilds on every deploy. +### Compiling a package and its dependencies (`xphp.json`) + +A single source directory is enough for one self-contained project, but a +package that *ships* `.xphp` templates — and any app that *consumes* one — +needs to compile several source roots together. Instead of staging them into +one tree, drop an **`xphp.json`** manifest at the package root and let the +compiler discover the roots: + +```json +{ + "sources": ["src"], + "include": ["vendor/*/*"], + "target": "dist", + "cache": ".xphp-cache" +} +``` + +- `sources` — this package's own `.xphp` roots (relative to the manifest). + Omitted ⇒ `["."]`. +- `include` — other packages to pull in, transitively. Each entry is a directory + or a **glob** (`*`/`?`/`[…]`; recursive `**` is rejected). A glob auto-discovers: + any matched directory that has its own `xphp.json` is compiled in, others are + skipped — so `"vendor/*/*"` picks up every installed xphp package and needs no + edit when you add another. An explicit (non-glob) entry without an `xphp.json` + is an error. +- `target`/`cache` — optional output dirs (CLI `--target`/`--cache` override). + +Then compile (or check) against the manifest — `--config`, or just run where the +`xphp.json` is auto-detected: + +```bash +vendor/bin/xphp compile --config xphp.json # or: vendor/bin/xphp compile (auto-detect) +vendor/bin/xphp check --config xphp.json +``` + +**Distribution model.** A library ships its `.xphp` *sources* plus its `xphp.json` +(via Composer). A downstream build pulls those sources and compiles the whole +union into its own output — so the upstream's marker interfaces and the +specializations your call sites need all get emitted, and everything runs without +the library being pre-compiled. The single-directory `compile src dist cache` form +above keeps working unchanged. + ## 5. Run it Any normal PHP runtime that loads Composer's autoload will pick up From 5ee437d7f1cc96997eb71059f8046dfb04571b0f Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 22 Jun 2026 09:27:42 +0000 Subject: [PATCH 068/114] feat(config): support `**` globstar for recursive include discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `include` globs only supported single-segment `*` (and rejected `**`), which is out of step with the standard recursive-glob convention used by gitignore, bash globstar, and the JS glob ecosystem — where `**` matches across path segments. Support it: an `include` entry containing `**` recursively discovers every directory under the literal prefix that has its own `xphp.json`, so `"vendor/**"` picks up every installed xphp package at any depth (the idiomatic form). The single-segment `*`/`?`/`[…]` path (native `glob`) is unchanged, and `"vendor/*/*"` still works for Composer's flat layout. Docs/ADR now recommend `vendor/**`. Also fixes a test that passed for the wrong reason: the package's `sources` defaulted to `["."]`, so the recursively-scanned files were found directly rather than through `**` discovery — now pinned with an explicit `src`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../0017-config-manifest-source-resolution.md | 5 +- docs/getting-started.md | 13 ++-- src/Config/Manifest.php | 2 +- src/Config/ManifestResolver.php | 64 +++++++++++++++---- test/Config/ManifestResolverTest.php | 28 ++++++-- 5 files changed, 84 insertions(+), 28 deletions(-) diff --git a/docs/adr/0017-config-manifest-source-resolution.md b/docs/adr/0017-config-manifest-source-resolution.md index e338965..b565440 100644 --- a/docs/adr/0017-config-manifest-source-resolution.md +++ b/docs/adr/0017-config-manifest-source-resolution.md @@ -34,8 +34,9 @@ developer hand-listing every dependency or re-editing config when a package is i Chosen: **an `xphp.json` manifest resolved into a multi-root source set.** A manifest declares `sources` (its own `.xphp` roots) and `include` (other packages, transitively). `include` entries -may be globs; a glob-matched directory with its own `xphp.json` is pulled in, others skipped, so -`"include": ["vendor/*/*"]` discovers every installed xphp package and is set once. Resolution +may be globs (`*`/`?`/`[…]` within a segment, or `**` for recursive discovery); a glob-matched +directory with its own `xphp.json` is pulled in, others skipped, so `"include": ["vendor/**"]` +discovers every installed xphp package at any depth and is set once. Resolution dedups by realpath (diamonds resolve once; cycles terminate). The CLI takes `--config ` or auto-detects `xphp.json` in the working directory; the single-directory positional form is unchanged (precedence: positional source → `--config` → auto-detect). diff --git a/docs/getting-started.md b/docs/getting-started.md index dc6b5e6..152bfbe 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -124,7 +124,7 @@ compiler discover the roots: ```json { "sources": ["src"], - "include": ["vendor/*/*"], + "include": ["vendor/**"], "target": "dist", "cache": ".xphp-cache" } @@ -133,11 +133,12 @@ compiler discover the roots: - `sources` — this package's own `.xphp` roots (relative to the manifest). Omitted ⇒ `["."]`. - `include` — other packages to pull in, transitively. Each entry is a directory - or a **glob** (`*`/`?`/`[…]`; recursive `**` is rejected). A glob auto-discovers: - any matched directory that has its own `xphp.json` is compiled in, others are - skipped — so `"vendor/*/*"` picks up every installed xphp package and needs no - edit when you add another. An explicit (non-glob) entry without an `xphp.json` - is an error. + or a **glob**: `*`/`?`/`[…]` match within one path segment, and `**` (globstar) + matches recursively. A glob auto-discovers: any matched directory that has its + own `xphp.json` is compiled in, others are skipped — so `"vendor/**"` picks up + every installed xphp package at any depth and needs no edit when you add another. + (`"vendor/*/*"` also works for Composer's flat `vendor//` layout.) An + explicit (non-glob) entry without an `xphp.json` is an error. - `target`/`cache` — optional output dirs (CLI `--target`/`--cache` override). Then compile (or check) against the manifest — `--config`, or just run where the diff --git a/src/Config/Manifest.php b/src/Config/Manifest.php index ed133b1..0d2ff7d 100644 --- a/src/Config/Manifest.php +++ b/src/Config/Manifest.php @@ -15,7 +15,7 @@ * @param list $sources This package's own `.xphp` source roots (relative). The parser * substitutes `["."]` (the manifest's own dir) when the key is absent. * @param list $include Other packages to pull in transitively — each a dir or a glob - * (`*`/`?`/`[…]` per segment; recursive `**` is rejected). The parser substitutes `[]` when absent. + * (`*`/`?`/`[…]` per segment, or `**` for recursive discovery). The parser substitutes `[]` when absent. * @param ?string $target Optional build output dir (entry manifest only; CLI overrides). * @param ?string $cache Optional generated-class cache dir (entry manifest only; CLI overrides). */ diff --git a/src/Config/ManifestResolver.php b/src/Config/ManifestResolver.php index a28bb2b..b8186f6 100644 --- a/src/Config/ManifestResolver.php +++ b/src/Config/ManifestResolver.php @@ -4,6 +4,9 @@ namespace XPHP\Config; +use FilesystemIterator; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; use RuntimeException; use XPHP\FileSystem\FileFinder; use XPHP\FileSystem\FilepathArray; @@ -12,9 +15,11 @@ /** * Resolves an `xphp.json` manifest into the full set of `.xphp` source roots to compile: the * package's own `sources` plus every transitively-`include`d package. `include` entries may be - * globs (`*`/`?`/`[…]` per segment, via native `glob`; recursive `**` is rejected) — matched - * directories that contain an `xphp.json` are pulled in and others skipped (vendor over-matches by - * design), while an explicit (non-glob) entry lacking an `xphp.json` is a hard error. + * globs: `*`/`?`/`[…]` match within a path segment (native `glob`), and `**` (globstar) matches + * recursively — discovering every directory at any depth that has its own `xphp.json`. Matched + * directories with an `xphp.json` are pulled in and others skipped (vendor over-matches by design; + * `"vendor/**"` is the idiomatic "every installed package" form), while an explicit (non-glob) + * entry lacking an `xphp.json` is a hard error. * * The walk dedups by realpath (diamonds resolve once) and is cycle-safe (a↔b terminates). Paths and * globs are resolved against each manifest's own directory. `target`/`cache` come from the entry @@ -144,27 +149,58 @@ private function parseFile(string $manifestPath): Manifest } /** - * Expand a glob (relative to $base) to the directories it matches, via the native libc glob - * with GLOB_ONLYDIR (so only directories are returned, sorted). Single-star segments are - * supported, so composer's flat `vendor//` layout is matched by a two-segment star - * glob under `vendor`. Recursive double-star is intentionally unsupported — rejected with a - * clear message rather than silently mis-handled. + * Expand a glob (relative to $base) to the directories it matches. + * + * A `**` (globstar) anywhere means recursive discovery: every directory under the literal + * prefix (the path up to the first `**`) that contains its own `xphp.json`. So `vendor/**` + * finds every installed xphp package at any depth. Otherwise the native libc glob runs with + * `GLOB_ONLYDIR` (single-segment `*`/`?`/`[…]`, directories only, sorted) — composer's flat + * `vendor//` layout is also matched by a two-segment single-star glob. * * @return list */ private function expandGlob(string $base, string $pattern): array { - if (str_contains($pattern, '**')) { - throw new RuntimeException(sprintf( - 'Invalid include glob "%s": "**" is not supported — use "*" per path segment (e.g. "vendor/*/*").', - $pattern, - )); + $full = self::join($base, $pattern); + + if (str_contains($full, '**')) { + // Recurse from the literal prefix (everything before the first `**`). + return self::discoverManifestDirs(explode('**', $full)[0]); } - $matches = glob(self::join($base, $pattern), GLOB_ONLYDIR); + + $matches = glob($full, GLOB_ONLYDIR); return $matches === false ? [] : $matches; } + /** + * Recursively find every directory under $base that contains an `xphp.json`. $base may carry a + * trailing slash; a non-existent base yields no matches. + * + * @return list + */ + private static function discoverManifestDirs(string $base): array + { + if (!is_dir($base)) { + return []; + } + $dirs = []; + /** @var iterable<\SplFileInfo> $iterator */ + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS), + ); + foreach ($iterator as $entry) { + if ($entry->getFilename() === self::MANIFEST_FILENAME) { + $dirs[] = $entry->getPath(); + } + } + // @infection-ignore-all -- ordering only; the resolved source set is order-independent, and + // native glob() returns sorted too — this just keeps `**` discovery deterministic. + sort($dirs); + + return $dirs; + } + private static function isGlob(string $s): bool { return strpbrk($s, '*?[') !== false; diff --git a/test/Config/ManifestResolverTest.php b/test/Config/ManifestResolverTest.php index d57277d..de0fb2a 100644 --- a/test/Config/ManifestResolverTest.php +++ b/test/Config/ManifestResolverTest.php @@ -101,13 +101,31 @@ public function testAbsolutePathIncludeIsResolved(): void self::assertSame(['Box.xphp', 'Use.xphp'], $this->basenames($r)); } - public function testDoubleStarGlobIsRejected(): void + public function testRecursiveGlobDiscoversPackagesAtAnyDepth(): void { - $this->pkg('app', '{"include":["packages/**"]}', []); + // `**` finds every dir with an xphp.json under the prefix, at any depth, skipping the rest. + // app's own `sources` is an explicit `src` (NOT the default ".", which would recursively + // grab packages/*.xphp directly) — so A/B are reachable ONLY through the `**` discovery. + $this->pkg('app', '{"sources":["src"],"include":["packages/**"]}', ['src/Root.xphp']); + $this->pkg('app/packages/a', '{"sources":["src"]}', ['src/A.xphp']); + $this->pkg('app/packages/nested/deep/b', '{"sources":["src"]}', ['src/B.xphp']); + mkdir($this->work . '/app/packages/plain', 0o755, true); // no xphp.json → not discovered + // A non-manifest file at depth must NOT be mistaken for a package. + file_put_contents($this->work . '/app/packages/notes.txt', 'x'); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('"**" is not supported'); - $this->resolve($this->work . '/app'); + $r = $this->resolve($this->work . '/app'); + + self::assertSame(['A.xphp', 'B.xphp', 'Root.xphp'], $this->basenames($r)); + } + + public function testRecursiveGlobOverMissingDirIsACleanNoOp(): void + { + $this->pkg('app', '{"sources":["src"],"include":["packages/**"]}', ['src/Use.xphp']); + // No `packages/` dir exists at all. + + $r = $this->resolve($this->work . '/app'); + + self::assertSame(['Use.xphp'], $this->basenames($r)); } public function testExplicitIncludeWithoutManifestIsHardError(): void From f326d87e0af914a036e774c241a3dde3ecf67133 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 22 Jun 2026 17:04:24 +0000 Subject: [PATCH 069/114] docs: recommend the xphp.json manifest as the default project setup The single-directory `compile ` form remains fully backward compatible; document it as the quick path and present the manifest as the recommended setup for any real project (and the required one for consuming another package's generics). README quick start gains the manifest form and links to the getting-started distribution-model section. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 28 ++++++++++++++++++++++++++++ docs/getting-started.md | 17 ++++++++++------- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 65bfe29..e778b13 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,34 @@ compile. `dist/` holds your rewritten code; `.xphp-cache/Generated/` holds the specialized classes. Both can be gitignored and rebuilt in CI. +For a real project — and **required** once you consume another package's +generics — drop an `xphp.json` manifest at the project root instead of +repeating the paths on every invocation: + +```json +{ + "sources": ["src"], + "include": ["vendor/**"], + "target": "dist", + "cache": ".xphp-cache" +} +``` + +Then `compile`/`check` take no positional source — they resolve the +manifest (auto-detected in the working directory, or via `--config`): + +```bash +vendor/bin/xphp compile # compiles this package + every included one +vendor/bin/xphp check +``` + +`include` globs auto-discover installed xphp packages (`vendor/**` finds +every one, at any depth, with no edit when you add another), so the +downstream build compiles the whole union into its own output. See +[Getting started](docs/getting-started.md#the-recommended-project-setup-an-xphpjson-manifest) +for the distribution model. The single-directory form above keeps working +unchanged. + To validate generics without emitting anything — a CI gate that reports every bound/variance/etc. problem with a `file:line`: diff --git a/docs/getting-started.md b/docs/getting-started.md index 152bfbe..9cd7350 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -113,13 +113,16 @@ After the compile completes you'll have: Both `dist/` and `.xphp-cache/` can be gitignored — they're generated artifacts your CI/CD pipeline rebuilds on every deploy. -### Compiling a package and its dependencies (`xphp.json`) - -A single source directory is enough for one self-contained project, but a -package that *ships* `.xphp` templates — and any app that *consumes* one — -needs to compile several source roots together. Instead of staging them into -one tree, drop an **`xphp.json`** manifest at the package root and let the -compiler discover the roots: +### The recommended project setup: an `xphp.json` manifest + +The single-directory form above is the quickest way to compile one +self-contained tree (and it keeps working unchanged). But for any **real +project** — and **required** the moment you *consume* another package's +templates or *ship* your own — the recommended setup is an **`xphp.json`** +manifest at the project root. It's the project config (like `composer.json`): +it records your source roots and dependencies once, so `compile`/`check` take +no positional arguments, and it's what lets the compiler pull several source +roots together instead of staging them into one tree: ```json { From a7eb060d7b222816ca65bb3d2d2437d40f73ed7d Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 22 Jun 2026 22:04:49 +0000 Subject: [PATCH 070/114] feat(monomorphize): add Registry::substituteBound to ground bound type params Pure substitution over a BoundExpr tree (leaf/intersection/union/DNF), reusing Specializer::substituteTypeRef on each leaf's TypeRef so a type-parameter leaf is rewritten to a concrete type while other leaves pass through unchanged. This is the primitive for grounding a method-level bound that references an enclosing class type parameter against the receiver's concrete type arguments before the bound is checked. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/Registry.php | 35 +++++ .../RegistrySubstituteBoundTest.php | 144 ++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 test/Transpiler/Monomorphize/RegistrySubstituteBoundTest.php diff --git a/src/Transpiler/Monomorphize/Registry.php b/src/Transpiler/Monomorphize/Registry.php index 517f24e..5b2df33 100644 --- a/src/Transpiler/Monomorphize/Registry.php +++ b/src/Transpiler/Monomorphize/Registry.php @@ -714,6 +714,41 @@ private static function boundViolationMessage( ); } + /** + * Substitute type-param leaves inside a BoundExpr tree, returning a fresh tree. + * + * Grounds a method-generic bound that references an enclosing class type parameter + * (``) against the receiver's concrete type arguments before the bound is + * checked: each `BoundLeaf`'s `TypeRef` is rewritten via + * {@see Specializer::substituteTypeRef}, so a leaf `E` becomes the receiver's concrete + * `Product`, while a leaf the map does not mention is returned unchanged (and a leaf that + * stays a type-param signals an ungroundable bound to the caller). `Bound*` are immutable, + * so a fresh tree is built; the compound branches recurse so DNF shapes ground throughout. + * + * @param array $subst + */ + public static function substituteBound(BoundExpr $bound, array $subst): BoundExpr + { + if ($bound instanceof BoundLeaf) { + return new BoundLeaf(Specializer::substituteTypeRef($bound->type, $subst)); + } + if ($bound instanceof BoundIntersection) { + return new BoundIntersection(...array_map( + static fn (BoundExpr $op): BoundExpr => self::substituteBound($op, $subst), + $bound->operands, + )); + } + if ($bound instanceof BoundUnion) { + return new BoundUnion(...array_map( + static fn (BoundExpr $op): BoundExpr => self::substituteBound($op, $subst), + $bound->operands, + )); + } + // Defensive: BoundExpr is an abstract base and we own every subtype. Unreachable in + // any test, but keep the return shape consistent (mirrors evaluateBound). + return $bound; + } + /** * Three-way verdict (true / false / null) for a bound expression against a * concrete TypeRef. Walks the BoundExpr tree: diff --git a/test/Transpiler/Monomorphize/RegistrySubstituteBoundTest.php b/test/Transpiler/Monomorphize/RegistrySubstituteBoundTest.php new file mode 100644 index 0000000..2183ae9 --- /dev/null +++ b/test/Transpiler/Monomorphize/RegistrySubstituteBoundTest.php @@ -0,0 +1,144 @@ +`) against the + * receiver's concrete type arguments before the bound is checked. Leaves are rewritten via + * Specializer::substituteTypeRef; compound bounds (intersection / union / DNF) recurse. + */ +final class RegistrySubstituteBoundTest extends TestCase +{ + /** @param array $subst */ + private static function ground(BoundExpr $bound, array $subst): BoundExpr + { + return Registry::substituteBound($bound, $subst); + } + + public function testLeafTypeParamIsGroundedToConcrete(): void + { + $result = self::ground( + new BoundLeaf(new TypeRef('E', isTypeParam: true)), + ['E' => new TypeRef('App\\Product')], + ); + + self::assertInstanceOf(BoundLeaf::class, $result); + self::assertSame('App\\Product', $result->type->name); + self::assertFalse($result->type->isTypeParam); + } + + public function testLeafNotInSubstIsLeftIntact(): void + { + $result = self::ground( + new BoundLeaf(new TypeRef('E', isTypeParam: true)), + ['X' => new TypeRef('App\\Product')], + ); + + self::assertInstanceOf(BoundLeaf::class, $result); + self::assertSame('E', $result->type->name); + self::assertTrue($result->type->isTypeParam, 'an ungroundable leaf stays a type-param so the caller can detect it'); + } + + public function testConcreteLeafSharingASubstNameIsNotGrounded(): void + { + // Only type-param leaves ground: a concrete class that happens to be named `E` + // (isTypeParam: false) is left intact even when the map has an `E` key. + $result = self::ground( + new BoundLeaf(new TypeRef('E')), + ['E' => new TypeRef('App\\Product')], + ); + + self::assertInstanceOf(BoundLeaf::class, $result); + self::assertSame('E', $result->type->name); + self::assertFalse($result->type->isTypeParam); + } + + public function testEmptySubstIsANoOp(): void + { + $result = self::ground(new BoundLeaf(new TypeRef('E', isTypeParam: true)), []); + + self::assertInstanceOf(BoundLeaf::class, $result); + self::assertSame('E', $result->type->name); + self::assertTrue($result->type->isTypeParam); + } + + public function testNestedGenericLeafIsGroundedThroughArgs(): void + { + // F-bounded-shaped leaf `Comparable` grounds its inner arg. + $result = self::ground( + new BoundLeaf(new TypeRef('Comparable', [new TypeRef('E', isTypeParam: true)])), + ['E' => new TypeRef('App\\Product')], + ); + + self::assertInstanceOf(BoundLeaf::class, $result); + self::assertSame('Comparable', $result->type->name); + self::assertCount(1, $result->type->args); + self::assertSame('App\\Product', $result->type->args[0]->name); + } + + public function testIntersectionRecursesOverEveryOperand(): void + { + $result = self::ground( + new BoundIntersection( + new BoundLeaf(new TypeRef('App\\Named')), + new BoundLeaf(new TypeRef('E', isTypeParam: true)), + ), + ['E' => new TypeRef('App\\Product')], + ); + + self::assertInstanceOf(BoundIntersection::class, $result); + self::assertCount(2, $result->operands); + self::assertInstanceOf(BoundLeaf::class, $result->operands[0]); + self::assertSame('App\\Named', $result->operands[0]->type->name); + self::assertInstanceOf(BoundLeaf::class, $result->operands[1]); + self::assertSame('App\\Product', $result->operands[1]->type->name); + } + + public function testUnionRecursesOverEveryOperand(): void + { + $result = self::ground( + new BoundUnion( + new BoundLeaf(new TypeRef('E', isTypeParam: true)), + new BoundLeaf(new TypeRef('App\\Fallback')), + ), + ['E' => new TypeRef('App\\Product')], + ); + + self::assertInstanceOf(BoundUnion::class, $result); + self::assertCount(2, $result->operands); + self::assertInstanceOf(BoundLeaf::class, $result->operands[0]); + self::assertSame('App\\Product', $result->operands[0]->type->name); + self::assertInstanceOf(BoundLeaf::class, $result->operands[1]); + self::assertSame('App\\Fallback', $result->operands[1]->type->name); + } + + public function testDnfGroundsLeafNestedInsideUnionOfIntersections(): void + { + // (E & A) | B — the type-param leaf is two levels deep; recursion must reach it. + $result = self::ground( + new BoundUnion( + new BoundIntersection( + new BoundLeaf(new TypeRef('E', isTypeParam: true)), + new BoundLeaf(new TypeRef('App\\A')), + ), + new BoundLeaf(new TypeRef('App\\B')), + ), + ['E' => new TypeRef('App\\Product')], + ); + + self::assertInstanceOf(BoundUnion::class, $result); + $inner = $result->operands[0]; + self::assertInstanceOf(BoundIntersection::class, $inner); + self::assertInstanceOf(BoundLeaf::class, $inner->operands[0]); + self::assertSame('App\\Product', $inner->operands[0]->type->name); + self::assertInstanceOf(BoundLeaf::class, $inner->operands[1]); + self::assertSame('App\\A', $inner->operands[1]->type->name); + self::assertInstanceOf(BoundLeaf::class, $result->operands[1]); + self::assertSame('App\\B', $result->operands[1]->type->name); + } +} From 3bf3894ace683eeac03e0f5a11bc05c7234de15e Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 22 Jun 2026 22:18:56 +0000 Subject: [PATCH 071/114] feat(monomorphize): thread receiver type args through the supertype chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TypeHierarchy now captures the parameterized extends/implements edges (the type arguments each clause passes, e.g. `implements Collection`) and each class's own type-parameter names, alongside the existing erased ancestor map. The new resolveInheritedArgs() walks that parameterized graph to ground a receiver's concrete arguments at a method's declaring class — so a method declared on a generic supertype can be checked against the receiver's actual type arguments. The walk is a depth-first traversal with a per-path visited set: diamonds resolve once when they agree and report null when they conflict, while regular cycles and expansive `A implements A>` recursion terminate without a magic cap. Gaps (unreachable target, arity mismatch) and ambiguity both yield null, which the caller reads as "cannot ground". Additive: the two new maps default to empty, so existing callers and the erased isSubtype/ancestorChain queries are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/TypeHierarchy.php | 169 +++++++++- .../TypeHierarchyInheritedArgsTest.php | 304 ++++++++++++++++++ 2 files changed, 460 insertions(+), 13 deletions(-) create mode 100644 test/Transpiler/Monomorphize/TypeHierarchyInheritedArgsTest.php diff --git a/src/Transpiler/Monomorphize/TypeHierarchy.php b/src/Transpiler/Monomorphize/TypeHierarchy.php index e3e7506..01154a7 100644 --- a/src/Transpiler/Monomorphize/TypeHierarchy.php +++ b/src/Transpiler/Monomorphize/TypeHierarchy.php @@ -57,10 +57,23 @@ ]; /** + * `$ancestors` powers the erased `isSubtype`/`ancestorChain` queries. `$superTypeArgs` and + * `$typeParamNames` additionally model the *parameterized* supertype edges — the type + * arguments each `extends`/`implements` clause passes (`implements Collection`) and each + * class's own declared parameter names — so `resolveInheritedArgs` can thread a receiver's + * concrete arguments up the chain to a method's declaring class. Both default to empty: a + * hierarchy built without them (e.g. hand-constructed in a test, or from a non-xphp AST) still + * answers the erased queries, and `resolveInheritedArgs` simply finds nothing to ground. + * * @param array> $ancestors map> + * @param array> $superTypeArgs map> + * @param array> $typeParamNames map> */ - public function __construct(private array $ancestors) - { + public function __construct( + private array $ancestors, + private array $superTypeArgs = [], + private array $typeParamNames = [], + ) { } /** @@ -71,10 +84,12 @@ public function __construct(private array $ancestors) public static function fromAstPerFile(array $astPerFile): self { $ancestors = []; + $superTypeArgs = []; + $typeParamNames = []; foreach ($astPerFile as $ast) { - self::collectFromAst($ast, $ancestors); + self::collectFromAst($ast, $ancestors, $superTypeArgs, $typeParamNames); } - return new self($ancestors); + return new self($ancestors, $superTypeArgs, $typeParamNames); } /** @@ -172,20 +187,114 @@ public function ancestorChain(string $fqn): array return $chain; } + /** + * Thread a receiver's concrete type arguments up the parameterized supertype chain. + * + * Given a receiver of static type `$subFqn<$subArgs>` and a `$superFqn` reachable through its + * `extends`/`implements` clauses, returns the type arguments that `$superFqn`'s OWN parameters + * are bound to as witnessed from that receiver. For `class ArrayList<+E> implements Collection`, + * `resolveInheritedArgs('App\ArrayList', [Product], 'App\Collection')` yields `[Product]`. At each + * hop the current class's parameters are substituted with the current arguments into the supertype + * clause's arguments (so nested clauses like `implements Foo>` ground throughout). + * + * Returns null on any gap — an unreachable target, a non-parameterized/arity-mismatched hop, or a + * cyclic/expansive edge — and on ambiguity: when more than one path grounds the target to + * non-equal arguments. The caller reads null as "cannot ground; fall back to lenient". A direct + * hit (`$subFqn === $superFqn`) returns `$subArgs` unchanged. + * + * @param list $subArgs + * @return list|null + */ + public function resolveInheritedArgs(string $subFqn, array $subArgs, string $superFqn): ?array + { + /** @var array> $groundings canonical-args => the grounded args (dedup) */ + $groundings = []; + $this->groundPaths(ltrim($subFqn, '\\'), $subArgs, ltrim($superFqn, '\\'), [], $groundings); + + // 0 groundings = unreachable; >1 distinct = ambiguous (conflicting paths). Either way: null. + if (count($groundings) !== 1) { + return null; + } + + return array_values($groundings)[0]; + } + + /** + * Depth-first walk of the parameterized supertype edges, accumulating every distinct grounding of + * `$superFqn` into `$groundings` (keyed by canonical args, so a diamond that agrees collapses to + * one and a diamond that conflicts yields two). `$onPath` is the set of FQNs on the current path, + * passed by value so siblings stay independent (diamonds work) while a repeat on one path — a + * regular cycle or an expansive `A implements A>` recursion — terminates that path. + * + * @param list $args + * @param array $onPath + * @param array> $groundings + */ + private function groundPaths(string $fqn, array $args, string $superFqn, array $onPath, array &$groundings): void + { + if ($fqn === $superFqn) { + $groundings[self::argsKey($args)] = $args; + return; + } + if (isset($onPath[$fqn])) { + return; // cycle / expansive recursion on this path — a gap, not a grounding. + } + $params = $this->typeParamNames[$fqn] ?? []; + if (count($params) !== count($args)) { + return; // arity mismatch (incl. a non-parameterized hop carrying args) — a gap. + } + $subst = []; + foreach ($params as $i => $name) { + $subst[$name] = $args[$i]; + } + // @infection-ignore-all TrueValue -- the on-path guard keys on isset() (existence, not + // value), so the assigned literal is immaterial; the cycle/expansive tests pin termination. + $onPath[$fqn] = true; + foreach ($this->superTypeArgs[$fqn] ?? [] as $clause) { + $nextArgs = array_map( + static fn (TypeRef $a): TypeRef => Specializer::substituteTypeRef($a, $subst), + $clause->args, + ); + $this->groundPaths(ltrim($clause->name, '\\'), $nextArgs, $superFqn, $onPath, $groundings); + } + } + + /** + * Canonical key for an argument list, used to dedup groundings and detect conflict. + * + * @param list $args + */ + private static function argsKey(array $args): string + { + return implode(',', array_map(static fn (TypeRef $a): string => $a->canonical(), $args)); + } + /** * @param list $ast * @param array> $ancestors out-param accumulator + * @param array> $superTypeArgs out-param: parameterized direct supertypes + * @param array> $typeParamNames out-param: each class's own param names + * @param-out array> $ancestors + * @param-out array> $superTypeArgs + * @param-out array> $typeParamNames */ - private static function collectFromAst(array $ast, array &$ancestors): void + private static function collectFromAst(array $ast, array &$ancestors, array &$superTypeArgs, array &$typeParamNames): void { // @infection-ignore-all — the inner visitor is a flat AST walk over namespace/use - // /classlike nodes; mutations on its `?->`, `??` lastSegment fallback and - // `ltrim('\\')` defensives all toggle paths that are masked by nikic's - // representation (FQ names come without a leading backslash, anonymous namespaces - // aren't part of any fixture). End-to-end coverage from TypeHierarchyTest. + // /classlike nodes; mutations on its `?->`, `??` lastSegment fallback, the + // `ltrim('\\')` defensives and the `is_array` attribute guards all toggle paths that + // are masked by nikic's representation (FQ names come without a leading backslash, + // anonymous namespaces aren't part of any fixture, and a non-generic clause simply + // carries no ATTR_GENERIC_ARGS). The parameterized-supertype + param-name capture is + // end-to-end covered by TypeHierarchyTest (incl. a real-parser, aliased-arg case); the + // load-bearing grounding logic lives in resolveInheritedArgs, which IS mutation-tested. $visitor = new class extends NodeVisitorAbstract { /** @var array> */ public array $collected = []; + /** @var array> parameterized direct supertypes per class */ + public array $superArgs = []; + /** @var array> own type-param names per class */ + public array $paramNames = []; private string $currentNamespace = ''; /** @var array alias => FQN */ private array $useMap = []; @@ -209,22 +318,50 @@ public function enterNode(Node $node): null } if ($node instanceof ClassLike && $node->name !== null) { $selfFqn = $this->qualify($node->name->toString()); - $directAncestors = []; + /** @var list $clauses extends + implements clause names */ + $clauses = []; if ($node instanceof Class_) { if ($node->extends !== null) { - $directAncestors[] = $this->resolveName($node->extends); + $clauses[] = $node->extends; } foreach ($node->implements as $interface) { - $directAncestors[] = $this->resolveName($interface); + $clauses[] = $interface; } } elseif ($node instanceof Interface_) { foreach ($node->extends as $interface) { - $directAncestors[] = $this->resolveName($interface); + $clauses[] = $interface; } } // Trait_ has no formal ancestors — uses-of-traits are statements inside the body // and would only matter for shared-method bounds, which we don't model. + $directAncestors = []; + $parameterized = []; + foreach ($clauses as $clause) { + $fqn = $this->resolveName($clause); + $directAncestors[] = $fqn; + // The clause Name carries the xphp parser's resolved generic args + // (`implements Collection` → [TypeRef(E, isTypeParam)]); a non-generic + // clause has none. The head FQN comes from resolveName so it keys the same + // way as the bare-ancestor map. + $rawArgs = $clause->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + $clauseArgs = []; + foreach (is_array($rawArgs) ? $rawArgs : [] as $arg) { + if ($arg instanceof TypeRef) { + $clauseArgs[] = $arg; + } + } + $parameterized[] = new TypeRef($fqn, $clauseArgs); + } $this->collected[$selfFqn] = $directAncestors; + $this->superArgs[$selfFqn] = $parameterized; + $rawParams = $node->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + $paramNames = []; + foreach (is_array($rawParams) ? $rawParams : [] as $param) { + if ($param instanceof TypeParam) { + $paramNames[] = $param->name; + } + } + $this->paramNames[$selfFqn] = $paramNames; } return null; } @@ -275,5 +412,11 @@ private static function lastSegment(string $name): string foreach ($visitor->collected as $fqn => $direct) { $ancestors[$fqn] = $direct; } + foreach ($visitor->superArgs as $fqn => $supers) { + $superTypeArgs[$fqn] = $supers; + } + foreach ($visitor->paramNames as $fqn => $names) { + $typeParamNames[$fqn] = $names; + } } } diff --git a/test/Transpiler/Monomorphize/TypeHierarchyInheritedArgsTest.php b/test/Transpiler/Monomorphize/TypeHierarchyInheritedArgsTest.php new file mode 100644 index 0000000..d741fc0 --- /dev/null +++ b/test/Transpiler/Monomorphize/TypeHierarchyInheritedArgsTest.php @@ -0,0 +1,304 @@ + $args */ + private static function ref(string $name, array $args = []): TypeRef + { + return new TypeRef($name, $args); + } + + /** + * @param array> $superTypeArgs + * @param array> $typeParamNames + */ + private static function hierarchy(array $superTypeArgs, array $typeParamNames): TypeHierarchy + { + return new TypeHierarchy([], $superTypeArgs, $typeParamNames); + } + + /** + * @param list|null $args + * @return list|null + */ + private static function canon(?array $args): ?array + { + return $args === null ? null : array_map(static fn (TypeRef $a): string => $a->canonical(), $args); + } + + public function testDirectHitReturnsSubArgsUnchanged(): void + { + $h = self::hierarchy([], ['App\\Box' => ['E']]); + + self::assertSame( + ['App\\Product'], + self::canon($h->resolveInheritedArgs('App\\Box', [self::ref('App\\Product')], 'App\\Box')), + ); + } + + public function testSingleHopGroundsTheClauseArg(): void + { + // ArrayList implements Collection + $h = self::hierarchy( + ['App\\ArrayList' => [self::ref('App\\Collection', [self::tp('E')])], 'App\\Collection' => []], + ['App\\ArrayList' => ['E'], 'App\\Collection' => ['E']], + ); + + self::assertSame( + ['App\\Product'], + self::canon($h->resolveInheritedArgs('App\\ArrayList', [self::ref('App\\Product')], 'App\\Collection')), + ); + } + + public function testMultiHopChainThreadsThrough(): void + { + // ArrayList -> OrderedCollection -> Collection + $h = self::hierarchy( + [ + 'App\\ArrayList' => [self::ref('App\\OrderedCollection', [self::tp('E')])], + 'App\\OrderedCollection' => [self::ref('App\\Collection', [self::tp('E')])], + 'App\\Collection' => [], + ], + ['App\\ArrayList' => ['E'], 'App\\OrderedCollection' => ['E'], 'App\\Collection' => ['E']], + ); + + self::assertSame( + ['App\\Product'], + self::canon($h->resolveInheritedArgs('App\\ArrayList', [self::ref('App\\Product')], 'App\\Collection')), + ); + } + + public function testReorderedMultiArgClauseIsPositionallyZipped(): void + { + // Rev implements Pair — params must zip positionally, swapped at the clause. + $h = self::hierarchy( + ['App\\Rev' => [self::ref('App\\Pair', [self::tp('B'), self::tp('A')])], 'App\\Pair' => []], + ['App\\Rev' => ['A', 'B'], 'App\\Pair' => ['K', 'V']], + ); + + self::assertSame( + ['App\\Y', 'App\\X'], + self::canon($h->resolveInheritedArgs('App\\Rev', [self::ref('App\\X'), self::ref('App\\Y')], 'App\\Pair')), + ); + } + + public function testNestedClauseArgGroundsThroughRecursion(): void + { + // Wrap implements Holder> — the type-param is nested inside the clause arg. + $h = self::hierarchy( + ['App\\Wrap' => [self::ref('App\\Holder', [self::ref('App\\Box', [self::tp('E')])])], 'App\\Holder' => []], + ['App\\Wrap' => ['E'], 'App\\Holder' => ['T']], + ); + + self::assertSame( + ['App\\Box'], + self::canon($h->resolveInheritedArgs('App\\Wrap', [self::ref('App\\Product')], 'App\\Holder')), + ); + } + + public function testTypeParamSubArgsPropagateUnchanged(): void + { + // The `$this`-receiver shape: subArgs are identity type-params, so the grounding stays a + // type-param (WI-4 then drops the bound leniently). + $h = self::hierarchy( + ['App\\ArrayList' => [self::ref('App\\Collection', [self::tp('E')])], 'App\\Collection' => []], + ['App\\ArrayList' => ['E'], 'App\\Collection' => ['E']], + ); + + self::assertSame( + ['E'], + self::canon($h->resolveInheritedArgs('App\\ArrayList', [self::tp('E')], 'App\\Collection')), + ); + } + + public function testDiamondWithAgreeingArgsResolvesOnce(): void + { + // A -> B, C; B -> D, C -> D. Both paths agree → single grounding. + $h = self::hierarchy( + [ + 'App\\A' => [self::ref('App\\B', [self::tp('E')]), self::ref('App\\C', [self::tp('E')])], + 'App\\B' => [self::ref('App\\D', [self::tp('E')])], + 'App\\C' => [self::ref('App\\D', [self::tp('E')])], + 'App\\D' => [], + ], + ['App\\A' => ['E'], 'App\\B' => ['E'], 'App\\C' => ['E'], 'App\\D' => ['E']], + ); + + self::assertSame( + ['App\\Product'], + self::canon($h->resolveInheritedArgs('App\\A', [self::ref('App\\Product')], 'App\\D')), + ); + } + + public function testDiamondWithConflictingArgsIsAmbiguousAndReturnsNull(): void + { + // A -> B, C>; B -> D, C -> D. The two paths ground D to Product vs + // Box — a conflict the resolver must report as null (not pick one arbitrarily). + $h = self::hierarchy( + [ + 'App\\A' => [ + self::ref('App\\B', [self::tp('E')]), + self::ref('App\\C', [self::ref('App\\Box', [self::tp('E')])]), + ], + 'App\\B' => [self::ref('App\\D', [self::tp('T')])], + 'App\\C' => [self::ref('App\\D', [self::tp('T')])], + 'App\\D' => [], + ], + ['App\\A' => ['E'], 'App\\B' => ['T'], 'App\\C' => ['T'], 'App\\D' => ['T']], + ); + + self::assertNull($h->resolveInheritedArgs('App\\A', [self::ref('App\\Product')], 'App\\D')); + } + + public function testMultiArgClauseUsingOnlyOneParamThreadsTheRightArg(): void + { + // ImmutableMap implements Collection — only the index-1 param flows up (the + // `Map::containsValue` shape). The dropped K must not leak into the grounding. + $h = self::hierarchy( + ['App\\ImmutableMap' => [self::ref('App\\Collection', [self::tp('V')])], 'App\\Collection' => []], + ['App\\ImmutableMap' => ['K', 'V'], 'App\\Collection' => ['E']], + ); + + self::assertSame( + ['App\\Product'], + self::canon($h->resolveInheritedArgs('App\\ImmutableMap', [self::ref('App\\Str'), self::ref('App\\Product')], 'App\\Collection')), + ); + } + + public function testClauseReferencingAnOutOfScopeParamPropagatesItUnchanged(): void + { + // A clause arg naming a type-param absent from the class's own params is left untouched by + // substitution, so it grounds to a stray type-param (which WI-4 then drops leniently). + $h = self::hierarchy( + ['App\\Odd' => [self::ref('App\\Collection', [self::tp('F')])], 'App\\Collection' => []], + ['App\\Odd' => ['E'], 'App\\Collection' => ['E']], + ); + + self::assertSame( + ['F'], + self::canon($h->resolveInheritedArgs('App\\Odd', [self::ref('App\\Product')], 'App\\Collection')), + ); + } + + public function testUnreachableTargetReturnsNull(): void + { + $h = self::hierarchy( + ['App\\ArrayList' => [self::ref('App\\Collection', [self::tp('E')])], 'App\\Collection' => []], + ['App\\ArrayList' => ['E'], 'App\\Collection' => ['E']], + ); + + self::assertNull($h->resolveInheritedArgs('App\\ArrayList', [self::ref('App\\Product')], 'App\\Stringable')); + } + + public function testArityMismatchAtAHopReturnsNull(): void + { + // ArrayList declares one param but the caller supplies two args — an unbridgeable gap. + $h = self::hierarchy( + ['App\\ArrayList' => [self::ref('App\\Collection', [self::tp('E')])], 'App\\Collection' => []], + ['App\\ArrayList' => ['E'], 'App\\Collection' => ['E']], + ); + + self::assertNull( + $h->resolveInheritedArgs('App\\ArrayList', [self::ref('App\\Product'), self::ref('App\\Extra')], 'App\\Collection'), + ); + } + + public function testRegularCycleTerminatesWithNull(): void + { + // A implements B, B implements A — the per-path visited set breaks the cycle. + $h = self::hierarchy( + [ + 'App\\A' => [self::ref('App\\B', [self::tp('E')])], + 'App\\B' => [self::ref('App\\A', [self::tp('E')])], + ], + ['App\\A' => ['E'], 'App\\B' => ['E']], + ); + + self::assertNull($h->resolveInheritedArgs('App\\A', [self::ref('App\\Product')], 'App\\Unreachable')); + } + + public function testExpansiveRecursionTerminatesWithNull(): void + { + // Loop implements Loop> — a non-regular (ever-growing) recursion. The on-path guard + // stops it on the second visit to Loop, so it returns promptly rather than looping forever. + $h = self::hierarchy( + ['App\\Loop' => [self::ref('App\\Loop', [self::ref('App\\Box', [self::tp('T')])])]], + ['App\\Loop' => ['T']], + ); + + self::assertNull($h->resolveInheritedArgs('App\\Loop', [self::ref('int')], 'App\\Unreachable')); + } + + public function testLeadingBackslashOnSubAndSuperFqnIsNormalized(): void + { + // Callers may pass fully-qualified (leading-`\`) names; the resolver normalizes both ends. + $h = self::hierarchy( + ['App\\ArrayList' => [self::ref('App\\Collection', [self::tp('E')])], 'App\\Collection' => []], + ['App\\ArrayList' => ['E'], 'App\\Collection' => ['E']], + ); + + self::assertSame( + ['App\\Product'], + self::canon($h->resolveInheritedArgs('\\App\\ArrayList', [self::ref('App\\Product')], '\\App\\Collection')), + ); + } + + public function testLeadingBackslashOnAClauseNameIsNormalized(): void + { + // A supertype clause whose head is fully-qualified still keys onto the target. + $h = self::hierarchy( + ['App\\ArrayList' => [self::ref('\\App\\Collection', [self::tp('E')])], 'App\\Collection' => []], + ['App\\ArrayList' => ['E'], 'App\\Collection' => ['E']], + ); + + self::assertSame( + ['App\\Product'], + self::canon($h->resolveInheritedArgs('App\\ArrayList', [self::ref('App\\Product')], 'App\\Collection')), + ); + } + + public function testCaptureFromRealParserGroundsAnAliasedSupertypeArg(): void + { + // Real xphp parse: the head FQN (App\Collection, via collectFromAst::resolveName) and the + // clause arg FQN (App\Models\Product, resolved by the parser from the `use` alias) come from + // two independent resolvers; a successful grounding proves they agree. + $src = <<<'PHP' + {} + class ArrayList implements Collection {} + PHP; + + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse($src); + $h = TypeHierarchy::fromAstPerFile(['/x.xphp' => $ast]); + + // ArrayList passes the concrete `Product` (not its own E) to Collection, so the grounding is + // Product regardless of the receiver's E argument. + self::assertSame( + ['App\\Models\\Product'], + self::canon($h->resolveInheritedArgs('App\\ArrayList', [self::ref('App\\Whatever')], 'App\\Collection')), + ); + } +} From bed233633ace7b94eb88f0bb7231e90a89fd8f64 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 22 Jun 2026 22:58:02 +0000 Subject: [PATCH 072/114] feat(monomorphize): ground a method-generic bound on an enclosing type parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A method-level type parameter bounded by an enclosing class type parameter — `class Box<+E> { contains(U $value): bool }` — now has its bound grounded against the receiver's concrete type argument before the bound is checked. So `Box::contains` is accepted when Book <: Product, and a genuine violation is rejected with the bound shown as the real type, not the literal `E`. This is the sound, element-typed alternative to a `mixed` parameter on a covariant collection (U is invariant — not method-level variance). The receiver's type arguments are recovered via flow typing (a parameter's declared type, a `new` local, a `$this->prop`) into parallel side-tables that mirror the existing scope maps through closure/arrow capture and branch reset/leave — a branch that reassigns a variable drops its args so a post-branch receiver can't ground on a stale or ambiguous argument. They are threaded up the parameterized extends/implements chain to the method's declaring class, so a method declared on a generic interface or base and inherited by a concrete collection grounds too. When the argument can't be determined (an opaque receiver, a `$this` call in the template body, or a static call, where the class parameter has no value) the bound is left unchecked rather than falsely rejected. The parser now builds a bound leaf that is a bare enclosing type parameter as a type-parameter reference (it never set that flag before), which is what lets the substitution ground it; this also lets the variance phase flag a variant parameter used as the bare leaf of a sibling parameter's bound, consistent with the existing nested-argument case. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 13 + ...ric-bounds-on-enclosing-type-parameters.md | 93 +++++ docs/adr/README.md | 1 + docs/syntax/type-bounds.md | 39 +- .../Monomorphize/GenericMethodCompiler.php | 233 ++++++++++- src/Transpiler/Monomorphize/Registry.php | 2 +- .../Monomorphize/XphpSourceParser.php | 10 +- .../EnclosingParamBoundIntegrationTest.php | 382 ++++++++++++++++++ .../RegistrySubstituteBoundTest.php | 2 +- .../TypeHierarchyInheritedArgsTest.php | 2 +- .../VariancePositionPhaseTest.php | 7 + 11 files changed, 773 insertions(+), 11 deletions(-) create mode 100644 docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md create mode 100644 test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 71c02b6..b8e7d1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Element-typed methods on covariant collections.** A method-level type parameter + bounded by an enclosing class type parameter — `class Box<+E> { public function + contains(U $value): bool }` — now has its bound **grounded** against the + receiver's concrete type argument: `Box::contains` is accepted when + `Book <: Product`, and a genuine violation (`Box::contains`) is rejected + with the bound shown as the real type, not `E`. The receiver's argument is threaded up + the `extends`/`implements` chain, so a method declared on a generic interface/base and + inherited by a concrete collection is grounded too. This is the sound, element-typed + alternative to a `mixed` parameter on a covariant `<+E>` collection (`U` is invariant — + not method-level variance). Where the receiver's argument can't be determined (an opaque + receiver, or a `$this` call inside the template body) the bound is left unchecked rather + than falsely rejected. See [type bounds](docs/syntax/type-bounds.md) and + [ADR-0018](docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md). - **`xphp check`** — a validate-without-emitting CI gate. It runs every generic validation `xphp compile` does (bounds, variance, defaults, missing/duplicate generics, unsupported closures), but collects **all** problems in one run — diff --git a/docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md b/docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md new file mode 100644 index 0000000..bd96905 --- /dev/null +++ b/docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md @@ -0,0 +1,93 @@ +# 18. Grounding a method-generic bound that references an enclosing class type parameter + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +A covariant collection `class Box<+E>` cannot take `E` in a parameter position, so an +element-consuming method (`contains`, `indexOf`, an immutable `withAdded`) classically falls back to +`mixed`. The *sound* spelling is a **method-level** type parameter bounded by the class parameter — +`public function contains(U $value): bool` — so the argument is constrained to a subtype of +the element type while the covariant `+E` never enters a parameter position (the same shape Hack +uses for the element-search methods on its covariant `ConstVector`). `U` is invariant, so this is **not** method-level +variance ([ADR-0014](0014-variance-markers-are-class-level-only.md)) — it only needs the enclosing +`E` to be resolved. + +But the bound `E` was evaluated against the literal type-parameter name: `isSubtype("Book", "E")` +treats `"E"` as a phantom class, always returns false, and rejects *valid* code +(`Box::contains` with `Book <: Product`) with a misleading +*"Book does not extend/implement E"*. Bound checking is otherwise nominal and erased +([ADR-0005](0005-nominal-erased-bound-checking.md)); the missing piece is grounding `E` to the +receiver's concrete type argument before the check. + +## Decision Drivers + +- Let a covariant collection expose element-consuming methods with a *real* element-typed parameter + instead of `mixed`, soundly. +- Never false-reject code that compiles today — a covariant generics library can't afford it. +- Keep the existing nominal/erased bound check; add grounding, don't replace it. +- Cover the shape real libraries use: methods declared on a generic **interface/base** and inherited + by concrete collections. + +## Decision Outcome + +Chosen: **at a method-generic call site, ground each bound that names an enclosing class type +parameter against the receiver's concrete type arguments, then run the existing bound check.** + +- The receiver's type arguments are recovered from flow typing (a parameter's declared + `Box`, a `new Box::()` local, a `$this->prop` of declared generic type) and + threaded up the parameterized `extends`/`implements` chain to the method's **declaring** class, so + a method inherited from `Collection<+E>` grounds against an `ArrayList` receiver. +- A bound leaf that is still a bare type parameter after grounding — the argument couldn't be + determined (an opaque/inherited-but-unparameterized receiver, a post-branch merged receiver, or a + `$this` call inside the still-uninstantiated template body) — has its bound **dropped for that + call** (treated as unbounded) rather than checked against the phantom name. +- A bound that does **not** name an enclosing parameter — a real class, or an F-bounded + `Comparable` leaf — is untouched and checked exactly as before. + +### Consequences + +- Good: the one place a covariant collection degraded to `mixed` now has a sound, element-typed + parameter; `Box::contains` is accepted and `Box::contains` is + rejected with the bound shown **grounded** (`Book`), not `E`. +- Trade-off — **lenient drop is a deliberate loosening, not "always sound".** Where the receiver's + argument can't be determined, an ungroundable bound goes from today's *hard reject* to a *silent + accept*, so a genuine violation the compiler can't analyze is no longer caught. We accept this + because the alternative (a hard error) re-introduces false-rejects on currently-compiling code, and + a blanket warning is noisy (a branch-merged receiver routinely drops its arguments). A *targeted* + `xphp check` diagnostic for the narrow "receiver known, arity correct, still ungroundable" case is + possible future work. +- Trade-off — **compound bounds drop whole.** A method bound like `` whose `E` can't + be grounded drops the entire bound, including the checkable `Named` operand. Narrow (it needs a + concrete operand intersected with an ungroundable enclosing parameter) and an extension of the + lenient-drop decision; a future refinement could drop only the ungrounded operand. +- Scope — **static methods are out.** A class type parameter is unbound in a static context, so a + static method's enclosing-parameter bound is never grounded (it falls to lenient drop). There is no + use case (the motivating methods are all instance methods). +- Boundary unchanged — grounding resolves the enclosing parameter to the receiver's argument; the + grounded bound is then checked nominally/erased as before. F-bounded and generic-argument bound + checking are unaffected. +- Variance — making a bare-type-parameter bound leaf a first-class type-parameter reference also + lets the variance phase see it: a covariant/contravariant class parameter used as the **bare** leaf + of a sibling class parameter's bound (`class Pair<+T, U : T>`) is now flagged, consistently with the + already-rejected inner-argument case (`Sortable<+T : Box>`) and the documented rule that bounds + are an invariant position. The supported method-level shape (`contains`, where `U` is a + *method* parameter) is unaffected. + +### Confirmation + +The grounding, the inheritance threading, and the lenient fallbacks are covered end-to-end: a direct +and an **inherited** (`ArrayList extends Base<+E>`) accept, a multi-argument +(`Pair::containsValue`) accept that grounds the right parameter, a reject whose +message shows the grounded bound, the unbounded method generic unchanged, and the lenient cases +(`$this` body, a parameter / property / closure-`use` receiver, and a branch-merge that drops +conflicting arguments). The receiver-argument threading is unit-tested for chains, diamonds +(agreeing → one grounding, conflicting → none), cycles, and arity gaps. The variance consistency is +pinned in the variance-position phase. See [type bounds](../syntax/type-bounds.md). + +## More Information + +- [ADR-0005](0005-nominal-erased-bound-checking.md) — the nominal, erased bound check this grounds into. +- [ADR-0014](0014-variance-markers-are-class-level-only.md) — why `U` is invariant (this is not method-level variance). +- [ADR-0004](0004-marker-interfaces-for-instanceof.md) — generic templates lower to empty markers. +- [Type bounds](../syntax/type-bounds.md), [variance](../syntax/variance.md). diff --git a/docs/adr/README.md b/docs/adr/README.md index 1c093a2..481fa9a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -35,3 +35,4 @@ should be added here as a new numbered file; copy | [0015](0015-variance-markers-on-private-properties.md) | Variance markers on private properties | Accepted | | [0016](0016-no-special-cased-value-equality-bound.md) | No special-cased value-equality bound (use ordinary generics) | Accepted | | [0017](0017-config-manifest-source-resolution.md) | Config-manifest, multi-root source resolution (`xphp.json`) | Accepted | +| [0018](0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md) | Grounding a method-generic bound that references an enclosing class type parameter | Accepted | diff --git a/docs/syntax/type-bounds.md b/docs/syntax/type-bounds.md index 1c19eb2..cdde2a7 100644 --- a/docs/syntax/type-bounds.md +++ b/docs/syntax/type-bounds.md @@ -87,8 +87,43 @@ once it sees `public int $value`. *variance* type argument is a non-failing warning rather than an error — see [variance](variance.md#unprovable-variance-edges). - Bounds are an **invariant position** for variance markers — `+T` - or `-T` are rejected inside a bound expression. See - [variance](variance.md). + or `-T` are rejected inside a bound expression (whether as a bare + leaf, `class Pair<+T, U : T>`, or nested, `Sortable<+T : Box>`). + See [variance](variance.md). + +## Bounding a method type parameter by the enclosing class parameter + +A method-level type parameter may be bounded by one of the **enclosing +class's** type parameters. This is the sound way to give a covariant +`<+E>` collection an element-consuming method without dropping to +`mixed`: the argument is constrained to a subtype of the element type, +while the covariant `+E` never enters a parameter position. + +```php +class Box<+E> { + // U is a method type parameter (invariant), bounded by the class's E. + public function contains(U $value): bool { /* ... */ } +} + +$box = new Box::(); +$box->contains::(new Book()); // OK — Book is a subtype of Product +``` + +At the call site the bound `E` is **grounded** to the receiver's +concrete type argument (`Product` for a `Box`), then checked +like any other bound. A genuine violation +(`Box` then `->contains::(...)`) is rejected, with the +message naming the grounded type (`Book`). The receiver's argument is +threaded through `extends`/`implements`, so a method declared on a +generic interface/base and inherited by a concrete collection grounds +the same way. + +When the receiver's argument can't be determined statically — an opaque +receiver, or a `$this->m::<...>()` call inside the class body, where `E` +has no concrete value yet — the bound is **left unchecked** for that +call rather than reported as a spurious violation. (Bounding a *static* +method's type parameter by the class parameter is likewise unchecked: a +class type parameter has no value in a static context.) ## Caveats diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index cc8c7ba..9342671 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -405,6 +405,20 @@ private function rewriteCallSites( * @var array */ private array $currentScopeLocalTypes = []; + /** + * Parallel side-tables to the two type maps above, carrying each tracked receiver's + * generic type arguments (`$b` of declared type `Box` → `[Product]`). Kept in + * lockstep with the string maps through scope push/restore and branch reset/leave; a + * branch that assigns a variable DROPS its args (never merges them), so a post-branch + * receiver falls back to lenient grounding rather than risking a stale/ambiguous arg. + * Read by `resolveReceiverTypeArgs` to ground a method-generic bound that references an + * enclosing class type parameter against the receiver's concrete arguments. + * + * @var array> + */ + private array $currentScopeParamTypeArgs = []; + /** @var array> */ + private array $currentScopeLocalTypeArgs = []; /** * Variable name -> the Closure or ArrowFunction AST node that was * assigned to it (only when the closure carries @@ -452,7 +466,7 @@ private function rewriteCallSites( * Branch snapshots are nested per-scope so that branches inside a closure * don't leak to branches in the enclosing function. * - * @var list, locals: array, branches: list, assigned: array, perBranchTypes: list>, armIndex: int}>}> + * @var list, locals: array, paramArgs: array>, localArgs: array>, branches: list, localArgsSnapshot: array>, assigned: array, perBranchTypes: list>, armIndex: int}>}> */ private array $scopeSnapshots = []; /** @@ -491,7 +505,7 @@ private function rewriteCallSites( * keeps `$x` instead of invalidating. See `P5.1-same-class-merge.md` * and the `computeMergedTypes` / `canMergeOnLeave` helpers below. * - * @var list, assigned: array, perBranchTypes: list>, armIndex: int}> + * @var list, localArgsSnapshot: array>, assigned: array, perBranchTypes: list>, armIndex: int}> */ private array $branchSnapshots = []; @@ -550,13 +564,19 @@ public function enterNode(Node $node): null // inside the closure are independent of branches in the parent. $parentParams = $this->currentScopeParamTypes; $parentLocals = $this->currentScopeLocalTypes; + $parentParamArgs = $this->currentScopeParamTypeArgs; + $parentLocalArgs = $this->currentScopeLocalTypeArgs; $this->scopeSnapshots[] = [ 'params' => $parentParams, 'locals' => $parentLocals, + 'paramArgs' => $parentParamArgs, + 'localArgs' => $parentLocalArgs, 'branches' => $this->branchSnapshots, ]; $this->currentScopeParamTypes = []; $this->currentScopeLocalTypes = []; + $this->currentScopeParamTypeArgs = []; + $this->currentScopeLocalTypeArgs = []; $this->branchSnapshots = []; // For closures: `use ($x)` explicitly imports outer variables. @@ -575,6 +595,12 @@ public function enterNode(Node $node): null if ($importedType !== null) { $this->currentScopeParamTypes[$importedName] = $importedType; } + $importedArgs = $parentParamArgs[$importedName] + ?? $parentLocalArgs[$importedName] + ?? null; + if ($importedArgs !== null) { + $this->currentScopeParamTypeArgs[$importedName] = $importedArgs; + } } } @@ -588,6 +614,12 @@ public function enterNode(Node $node): null foreach ($parentLocals as $importedName => $importedType) { $this->currentScopeParamTypes[$importedName] = $importedType; } + foreach ($parentParamArgs as $importedName => $importedArgs) { + $this->currentScopeParamTypeArgs[$importedName] = $importedArgs; + } + foreach ($parentLocalArgs as $importedName => $importedArgs) { + $this->currentScopeParamTypeArgs[$importedName] = $importedArgs; + } } // Declared parameter types overwrite any imported same-named @@ -605,6 +637,16 @@ public function enterNode(Node $node): null } if ($type instanceof Name) { $this->currentScopeParamTypes[$param->var->name] = $this->resolveClassName($type); + // Side-table the param's generic args (`Box $b` → [Product]) so a + // bound referencing the enclosing class param can be grounded. A shadowing + // param without generic args clears any imported stale entry. + $paramArgs = $type->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + if (is_array($paramArgs)) { + /** @var list $paramArgs */ + $this->currentScopeParamTypeArgs[$param->var->name] = $paramArgs; + } else { + unset($this->currentScopeParamTypeArgs[$param->var->name]); + } } } } @@ -616,6 +658,7 @@ public function enterNode(Node $node): null if (self::isBranchingParent($node)) { $this->branchSnapshots[] = [ 'snapshot' => $this->currentScopeLocalTypes, + 'localArgsSnapshot' => $this->currentScopeLocalTypeArgs, 'assigned' => [], 'perBranchTypes' => [], // If_'s body is the first arm (armIndex=0). @@ -641,6 +684,9 @@ public function enterNode(Node $node): null } $this->branchSnapshots[$top]['armIndex']++; $this->currentScopeLocalTypes = $this->branchSnapshots[$top]['snapshot']; + // Args track the string map: each arm restarts from the pre-branch snapshot, so a + // var an earlier arm set can't leak its args into a sibling arm. + $this->currentScopeLocalTypeArgs = $this->branchSnapshots[$top]['localArgsSnapshot']; } // Stage B flow typing: `$x = new ClassName(...)` records `$x`'s receiver // type for later MethodCall sites in the same scope. Lexical last-write @@ -665,6 +711,15 @@ public function enterNode(Node $node): null && $node->expr->class instanceof Name ) { $this->currentScopeLocalTypes[$assignedName] = $this->resolveClassName($node->expr->class); + // Side-table the constructed type's generic args (`new Box::()` → + // [Product]); a non-generic `new` clears any stale args from a prior write. + $newArgs = $node->expr->class->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + if (is_array($newArgs)) { + /** @var list $newArgs */ + $this->currentScopeLocalTypeArgs[$assignedName] = $newArgs; + } else { + unset($this->currentScopeLocalTypeArgs[$assignedName]); + } } // Track anonymous generic templates: `$id = fn(T $x) => $x` // or `$id = function(T $x): T { ... }`. The FuncCall-on- @@ -709,6 +764,8 @@ public function leaveNode(Node $node): ?Node if ($snapshot !== null) { $this->currentScopeParamTypes = $snapshot['params']; $this->currentScopeLocalTypes = $snapshot['locals']; + $this->currentScopeParamTypeArgs = $snapshot['paramArgs']; + $this->currentScopeLocalTypeArgs = $snapshot['localArgs']; $this->branchSnapshots = $snapshot['branches']; } else { // Defensive: matched enter/leave count is invariant of the @@ -717,6 +774,8 @@ public function leaveNode(Node $node): ?Node // pop on the next leave. $this->currentScopeParamTypes = []; $this->currentScopeLocalTypes = []; + $this->currentScopeParamTypeArgs = []; + $this->currentScopeLocalTypeArgs = []; $this->branchSnapshots = []; } } @@ -741,6 +800,7 @@ public function leaveNode(Node $node): ?Node // Restore to pre-branch state. $this->currentScopeLocalTypes = $popped['snapshot']; + $this->currentScopeLocalTypeArgs = $popped['localArgsSnapshot']; // P5.1 same-class merge: try to keep variables whose // every reachable arm assigned the same FQN, instead @@ -753,6 +813,11 @@ public function leaveNode(Node $node): ?Node } else { unset($this->currentScopeLocalTypes[$assignedName]); } + // Args are NOT merged across arms: even when the FQN agrees, the arms may + // have passed different type arguments (Box vs Box), so an + // assigned var always loses its args → the receiver falls to lenient + // grounding rather than risking a stale/ambiguous argument. + unset($this->currentScopeLocalTypeArgs[$assignedName]); if ($this->branchSnapshots !== []) { $parentTop = count($this->branchSnapshots) - 1; $this->branchSnapshots[$parentTop]['assigned'][$assignedName] = true; @@ -961,8 +1026,12 @@ private function rewriteStaticCall(StaticCall $node): ?Node $args = $padded; if ($this->hierarchy !== null) { + // Static grounding is out of scope: a class type parameter is unbound in a + // static context, so pass no receiver args — an enclosing-param bound on a + // static method falls to the lenient drop inside groundBounds. + $checkedParams = $this->groundBounds($params, $classFqn, [], $declaringFqn); Registry::checkBounds( - $params, + $checkedParams, $args, $this->hierarchy, $classFqn . '::' . $methodName . '<' . self::formatArgList($args) . '>', @@ -1058,8 +1127,13 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): $args = $padded; if ($this->hierarchy !== null) { + // Ground a method-generic bound that references an enclosing class type + // parameter (``) against the receiver's concrete arguments, threaded + // to the method's declaring class; an ungroundable bound drops to lenient. + $receiverArgs = $this->resolveReceiverTypeArgs($node->var); + $checkedParams = $this->groundBounds($params, $classFqn, $receiverArgs, $declaringFqn); Registry::checkBounds( - $params, + $checkedParams, $args, $this->hierarchy, $classFqn . '::' . $methodName . '<' . self::formatArgList($args) . '>', @@ -1223,6 +1297,157 @@ private function resolveReceiverFqn(Node $receiver): ?string return null; } + /** + * The receiver's concrete generic type arguments, parallel to {@see resolveReceiverFqn}: + * - `$this` -> the enclosing class's own type params, as identity TypeRefs (a + * method-generic bound on the uninstantiated template can't be + * grounded to a concrete, so this stays a type-param and drops). + * - `$var` -> the side-tabled args for that parameter / local. + * - `$this->prop` -> the property type's generic args. + * Empty when unknown -- the grounding step then falls back to lenient. + * + * @return list + */ + private function resolveReceiverTypeArgs(Node $receiver): array + { + if ($receiver instanceof Variable && is_string($receiver->name)) { + if ($receiver->name === 'this') { + $owner = $this->currentClassFqn !== null + ? ($this->classByFqn[$this->currentClassFqn] ?? null) + : null; + $params = $owner?->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + if (!is_array($params)) { + return []; + } + /** @var list $params */ + return array_map( + static fn (TypeParam $p): TypeRef => new TypeRef($p->name, isTypeParam: true), + $params, + ); + } + return $this->currentScopeParamTypeArgs[$receiver->name] + ?? $this->currentScopeLocalTypeArgs[$receiver->name] + ?? []; + } + if ($receiver instanceof PropertyFetch + && $receiver->var instanceof Variable + && $receiver->var->name === 'this' + && $receiver->name instanceof Identifier + && $this->currentClassFqn !== null + ) { + $owner = $this->classByFqn[$this->currentClassFqn] ?? null; + if ($owner !== null) { + $propName = $receiver->name->toString(); + foreach ($owner->stmts as $stmt) { + if (!$stmt instanceof Property) { + continue; + } + foreach ($stmt->props as $prop) { + if ($prop->name->toString() !== $propName) { + continue; + } + $type = $stmt->type; + if ($type instanceof NullableType) { + $type = $type->type; + } + if ($type instanceof Name) { + $propArgs = $type->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + /** @var list $result */ + $result = is_array($propArgs) ? $propArgs : []; + return $result; + } + } + } + } + } + return []; + } + + /** + * Ground each method type-param's bound against the receiver's concrete arguments. + * + * Builds a substitution from the declaring class's parameters to the receiver's + * arguments (threaded up the inheritance chain), rewrites every bound leaf with it, and — + * when a leaf is still a bare enclosing type parameter afterwards (no receiver args, an + * inherited/opaque receiver, or the `$this` template body) — DROPS that param's bound for + * this call rather than checking it against the phantom type-param name. A bound that + * doesn't reference an enclosing param (a real class, or an F-bounded `Comparable` + * leaf) is unaffected and checked exactly as before. + * + * @param list $params + * @param list $receiverArgs + * @return list + */ + private function groundBounds(array $params, string $receiverFqn, array $receiverArgs, string $declaringFqn): array + { + $classSubst = $this->classSubstitutionFor($receiverFqn, $receiverArgs, $declaringFqn); + + return array_map( + static function (TypeParam $param) use ($classSubst): TypeParam { + if ($param->bound === null) { + return $param; + } + $grounded = Registry::substituteBound($param->bound, $classSubst); + if (self::boundHasUngroundedLeaf($grounded)) { + return new TypeParam($param->name, null, $param->default, $param->variance); + } + return new TypeParam($param->name, $grounded, $param->default, $param->variance); + }, + $params, + ); + } + + /** + * The declaring-class-parameter => receiver-argument substitution, or `[]` when the + * receiver's arguments can't be threaded to the declaring class (no hierarchy, an + * unreachable/ambiguous chain, an arity mismatch, or a missing declaring class). + * + * @param list $receiverArgs + * @return array + */ + private function classSubstitutionFor(string $receiverFqn, array $receiverArgs, string $declaringFqn): array + { + if ($this->hierarchy === null) { + return []; + } + $declArgs = $this->hierarchy->resolveInheritedArgs($receiverFqn, $receiverArgs, $declaringFqn); + if ($declArgs === null) { + return []; + } + $owner = $this->classByFqn[$declaringFqn] ?? null; + $params = $owner?->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + if (!is_array($params) || count($params) !== count($declArgs)) { + return []; + } + /** @var list $params */ + $subst = []; + foreach ($params as $i => $param) { + $subst[$param->name] = $declArgs[$i]; + } + return $subst; + } + + /** + * Whether any leaf of the bound is still a bare type parameter (`isTypeParam`) — i.e. an + * enclosing class/method parameter that substitution couldn't ground. A leaf naming a + * real class with type-param ARGS (`Comparable`) is not "ungrounded": the hierarchy + * checks it erased on the leaf name, exactly as today. + */ + private static function boundHasUngroundedLeaf(BoundExpr $bound): bool + { + if ($bound instanceof BoundLeaf) { + return $bound->type->isTypeParam; + } + if ($bound instanceof BoundIntersection || $bound instanceof BoundUnion) { + foreach ($bound->operands as $operand) { + if (self::boundHasUngroundedLeaf($operand)) { + return true; + } + } + } + return false; + } + private function rewriteFuncCall(FuncCall $node): ?Node { $args = $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); diff --git a/src/Transpiler/Monomorphize/Registry.php b/src/Transpiler/Monomorphize/Registry.php index 5b2df33..85b8ed9 100644 --- a/src/Transpiler/Monomorphize/Registry.php +++ b/src/Transpiler/Monomorphize/Registry.php @@ -718,7 +718,7 @@ private static function boundViolationMessage( * Substitute type-param leaves inside a BoundExpr tree, returning a fresh tree. * * Grounds a method-generic bound that references an enclosing class type parameter - * (``) against the receiver's concrete type arguments before the bound is + * (``) against the receiver's concrete type arguments before the bound is * checked: each `BoundLeaf`'s `TypeRef` is rewritten via * {@see Specializer::substituteTypeRef}, so a leaf `E` becomes the receiver's concrete * `Product`, while a leaf the map does not mention is returned unchanged (and a leaf that diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index a1e5f94..3ca3597 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -1774,12 +1774,18 @@ private function buildDefault(array $entry): ?TypeRef private function buildBoundExprNode(array $node): BoundExpr { if ($node['kind'] === 'leaf') { + $resolvedArgs = $this->resolveTypeRefList($node['args']); + // A bound that is a bare enclosing type parameter (`U : E`, or `B : A` over an + // earlier param) is kept as an `isTypeParam` TypeRef -- mirroring resolveTypeRef + // -- so the call site can ground it against the receiver's concrete argument + // rather than treating `E` as a phantom class name. + if (!$node['isFq'] && $this->isEnclosingTypeParam($node['name'])) { + return new BoundLeaf(new TypeRef($node['name'], $resolvedArgs, isTypeParam: true)); + } $fqn = $node['isFq'] ? $node['name'] : $this->resolveNameOnly($node['name']); - $resolvedArgs = $this->resolveTypeRefList($node['args']); $suspect = !$node['isFq'] - && !$this->isEnclosingTypeParam($node['name']) && $this->isSuspectUndeclared($node['name']); return new BoundLeaf(new TypeRef($fqn, $resolvedArgs, suspectUndeclared: $suspect)); } diff --git a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php new file mode 100644 index 0000000..e28cef9 --- /dev/null +++ b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php @@ -0,0 +1,382 @@ +`) against the receiver's concrete type argument — the element-consuming + * method shape on a covariant collection. Accept/reject/multi-arg pin that the bound is checked + * against the *grounded* type (not the literal `E`); the lenient cases pin that ungroundable + * receivers fall back to "no check" rather than the old misleading rejection. + */ +final class EnclosingParamBoundIntegrationTest extends TestCase +{ + private string $work; + + protected function setUp(): void + { + $this->work = sys_get_temp_dir() . '/xphp-encbound-' . uniqid('', true); + mkdir($this->work, 0o755, true); + } + + protected function tearDown(): void + { + self::rrmdir($this->work); + } + + /** Shared model: Book extends Product extends Item. */ + private const MODELS = <<<'PHP' + compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => <<<'PHP' + { + public function contains(U $value): bool { return true; } + } + PHP, + 'Use.xphp' => <<<'PHP' + (); + $box->contains::(new Book()); + PHP, + ]); + + // Compiled without a bound violation, and the call site was specialized (the method was + // mangled, not left as a bare `contains`). + self::assertStringContainsString('contains_', self::read($dist, 'Use.php')); + } + + public function testInheritedEnclosingParamBoundAcceptsSubtype(): void + { + // `contains` is declared on a generic BASE; the receiver is a subclass. Grounding must + // thread the receiver's `Product` through `extends Base` to the base's `E`. + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Base.xphp' => <<<'PHP' + { + public function contains(U $value): bool { return true; } + } + PHP, + 'ArrayList.xphp' => <<<'PHP' + extends Base {} + PHP, + 'Use.xphp' => <<<'PHP' + (); + $list->contains::(new Book()); + PHP, + ]); + + $this->addToAssertionCount(1); // reaching here = compiled with no bound violation. + } + + public function testMultiArgEnclosingParamBoundGroundsTheRightParameter(): void + { + // `Pair::containsValue` — grounding must pick V (index 1), not K. If it used + // K (Item), `Book <: Item` would also pass, so make K a type Book is NOT a subtype of. + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Key.xphp' => " <<<'PHP' + { + public function containsValue(U $value): bool { return true; } + } + PHP, + 'Use.xphp' => <<<'PHP' + (); + $pair->containsValue::(new Book()); + PHP, + ]); + + $this->addToAssertionCount(1); + } + + public function testEnclosingParamBoundRejectsNonSubtypeWithGroundedMessage(): void + { + // Box::contains — Product is NOT a subtype of Book, so this must reject, and + // the message must show the GROUNDED bound (`Book`), not the literal type parameter `E`. + try { + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => <<<'PHP' + { + public function contains(U $value): bool { return true; } + } + PHP, + 'Use.xphp' => <<<'PHP' + (); + $box->contains::(new Product()); + PHP, + ]); + self::fail('Expected a bound violation for Box::contains.'); + } catch (RuntimeException $e) { + $msg = $e->getMessage(); + self::assertStringContainsString('Generic bound violated', $msg); + self::assertStringContainsString('extend/implement "App\\Book"', $msg, 'bound must be grounded to the receiver arg Book'); + self::assertStringNotContainsString('"E"', $msg, 'must not report the literal type parameter E'); + } + } + + public function testUnboundedMethodGenericIsUnaffected(): void + { + $dist = $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => <<<'PHP' + { + public function pick(U $value): U { return $value; } + } + PHP, + 'Use.xphp' => <<<'PHP' + (); + $box->pick::(new Book()); + PHP, + ]); + + self::assertStringContainsString('pick_', self::read($dist, 'Use.php')); + } + + public function testThisReceiverEnclosingBoundIsLenient(): void + { + // `$this->contains::()` inside the template body: E has no concrete value yet, so the + // bound is dropped (lenient) rather than rejected against the phantom `E`. + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => <<<'PHP' + { + public function contains(U $value): bool { return true; } + public function probe(): bool { return $this->contains::(new Book()); } + } + PHP, + 'Use.xphp' => <<<'PHP' + (); + PHP, + ]); + + $this->addToAssertionCount(1); + } + + public function testParameterReceiverGroundsAndRejects(): void + { + // A parameter typed `Box` must ground `contains` to Book; Product is not a + // subtype, so this rejects — proving the param's type args are tracked and grounded. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('extend/implement "App\\Book"'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::box(), + 'Use.xphp' => <<<'PHP' + $b): bool { return $b->contains::(new Product()); } + PHP, + ]); + } + + public function testPropertyReceiverGroundsAndRejects(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('extend/implement "App\\Book"'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::box(), + 'Holder.xphp' => <<<'PHP' + $b; + public function run(): bool { return $this->b->contains::(new Product()); } + } + PHP, + ]); + } + + public function testBranchMergeDropsArgsAndFallsBackToLenient(): void + { + // Both arms assign a Box but with DIFFERENT args; the FQN merges (still Box) yet the args + // conflict, so they are dropped → the call is lenient. If the args were not dropped, the + // call would ground to one arm's type and wrongly reject `Item` (a supertype of both). + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::box(), + 'Use.xphp' => <<<'PHP' + (); } else { $box = new Box::(); } + return $box->contains::(new Item()); + } + PHP, + ]); + + $this->addToAssertionCount(1); + } + + public function testArgsSurviveABranchThatDoesNotTouchTheReceiver(): void + { + // The receiver is set before the branch and never reassigned inside it, so its args survive + // and ground the call — Product is not a subtype of Book, so it still rejects. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('extend/implement "App\\Book"'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::box(), + 'Use.xphp' => <<<'PHP' + (); + if ($c) { $unrelated = 1; } + return $box->contains::(new Product()); + } + PHP, + ]); + } + + public function testClosureUseReceiverGroundsAndRejects(): void + { + // `use ($box)` imports the outer local's args into the closure scope, so the bound grounds + // to Book inside the closure body and rejects Product. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('extend/implement "App\\Book"'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::box(), + 'Use.xphp' => <<<'PHP' + (); + return function () use ($box): bool { return $box->contains::(new Product()); }; + } + PHP, + ]); + } + + // --- harness --- + + private static function box(): string + { + return <<<'PHP' + { + public function contains(U $value): bool { return true; } + } + PHP; + } + + /** + * @param array $files filename => contents + * @return string the dist (target) directory + */ + private function compile(array $files): string + { + $src = $this->work . '/' . uniqid('src', true); + mkdir($src, 0o755, true); + foreach ($files as $name => $contents) { + file_put_contents($src . '/' . $name, $contents); + } + $dist = $src . '/dist'; + $cache = $src . '/.xphp-cache'; + + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + $compiler = new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + $sources = (new NativeFileFinder())->find($src) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + $compiler->compile($sources, $src, $dist, $cache); + + return $dist; + } + + private static function read(string $dir, string $file): string + { + $path = $dir . '/' . $file; + return is_file($path) ? (file_get_contents($path) ?: '') : ''; + } + + private static function rrmdir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . '/' . $entry; + is_dir($path) ? self::rrmdir($path) : unlink($path); + } + rmdir($dir); + } +} diff --git a/test/Transpiler/Monomorphize/RegistrySubstituteBoundTest.php b/test/Transpiler/Monomorphize/RegistrySubstituteBoundTest.php index 2183ae9..386f02d 100644 --- a/test/Transpiler/Monomorphize/RegistrySubstituteBoundTest.php +++ b/test/Transpiler/Monomorphize/RegistrySubstituteBoundTest.php @@ -8,7 +8,7 @@ /** * Unit coverage for {@see Registry::substituteBound} — the pure primitive that grounds a - * method-generic bound referencing an enclosing class type parameter (``) against the + * method-generic bound referencing an enclosing class type parameter (``) against the * receiver's concrete type arguments before the bound is checked. Leaves are rewritten via * Specializer::substituteTypeRef; compound bounds (intersection / union / DNF) recurse. */ diff --git a/test/Transpiler/Monomorphize/TypeHierarchyInheritedArgsTest.php b/test/Transpiler/Monomorphize/TypeHierarchyInheritedArgsTest.php index d741fc0..00354b4 100644 --- a/test/Transpiler/Monomorphize/TypeHierarchyInheritedArgsTest.php +++ b/test/Transpiler/Monomorphize/TypeHierarchyInheritedArgsTest.php @@ -171,7 +171,7 @@ public function testDiamondWithConflictingArgsIsAmbiguousAndReturnsNull(): void public function testMultiArgClauseUsingOnlyOneParamThreadsTheRightArg(): void { // ImmutableMap implements Collection — only the index-1 param flows up (the - // `Map::containsValue` shape). The dropped K must not leak into the grounding. + // `Map::containsValue` shape). The dropped K must not leak into the grounding. $h = self::hierarchy( ['App\\ImmutableMap' => [self::ref('App\\Collection', [self::tp('V')])], 'App\\Collection' => []], ['App\\ImmutableMap' => ['K', 'V'], 'App\\Collection' => ['E']], diff --git a/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php b/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php index 8375839..7c7f3db 100644 --- a/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php +++ b/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php @@ -44,6 +44,13 @@ public static function rejectedSources(): iterable ">\n{\n public function get(): T { throw new \\LogicException; }\n}\n", ['bound'], ]; + // A covariant param as the BARE leaf of a sibling class param's bound is a bound position + // too, flagged consistently with the inner-arg `Box` case above. (Distinct from the + // supported method-level `contains` shape, where U is a *method* type parameter.) + yield 'covariant in sibling bare bound' => [ + "\n{\n public function get(): T { throw new \\LogicException; }\n}\n", + ['bound'], + ]; // NOTE: `+T` in a *non-promoted* constructor parameter of a variant class is // ALLOWED — a constructor parameter is variance-exempt (constructors aren't // called through upcast references, and PHP exempts `__construct` from LSP), so From d4c7f71889eef552ad420632df5b3f73264b3ee7 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 23 Jun 2026 19:58:29 +0000 Subject: [PATCH 073/114] test(monomorphize): pin the enclosing-param bound grounding limitations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two compile fixtures + tests that lock the current behaviour of the two documented limitations (ADR-0018): when the receiver's element type can't be grounded, a bound that references it is dropped and a real violation is silently accepted. - lenient drop: a bare `` bound on a branch-merged Box receiver accepts `contains::` (a Rock is not a Fruit). - compound drop: a `` bound drops whole, so `register::` is accepted even though Banana is not Named (Banana only satisfies the element half, Banana <: Fruit). Each is paired with a groundable control that compiles the identical violation with a straight-line receiver and is correctly rejected — proving the masking is real and giving a future v2 a red->green target (the lenient assertions are the ones to flip). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../EnclosingParamBoundLimitationTest.php | 228 ++++++++++++++++++ .../source/Box.xphp | 12 + .../source/Models.xphp | 13 + .../source/Use.xphp | 24 ++ .../source/Box.xphp | 15 ++ .../source/Models.xphp | 11 + .../source/Use.xphp | 22 ++ 7 files changed, 325 insertions(+) create mode 100644 test/Transpiler/Monomorphize/EnclosingParamBoundLimitationTest.php create mode 100644 test/fixture/compile/enclosing_param_bound_compound_drop/source/Box.xphp create mode 100644 test/fixture/compile/enclosing_param_bound_compound_drop/source/Models.xphp create mode 100644 test/fixture/compile/enclosing_param_bound_compound_drop/source/Use.xphp create mode 100644 test/fixture/compile/enclosing_param_bound_lenient_drop/source/Box.xphp create mode 100644 test/fixture/compile/enclosing_param_bound_lenient_drop/source/Models.xphp create mode 100644 test/fixture/compile/enclosing_param_bound_lenient_drop/source/Use.xphp diff --git a/test/Transpiler/Monomorphize/EnclosingParamBoundLimitationTest.php b/test/Transpiler/Monomorphize/EnclosingParamBoundLimitationTest.php new file mode 100644 index 0000000..c635a38 --- /dev/null +++ b/test/Transpiler/Monomorphize/EnclosingParamBoundLimitationTest.php @@ -0,0 +1,228 @@ + */ + private array $workDirs = []; + + protected function tearDown(): void + { + foreach ($this->workDirs as $dir) { + self::rrmdir($dir); + } + $this->workDirs = []; + } + + // --- Limitation 1: bare `` bound is dropped when E is ungroundable --- + + public function testLenientDropAcceptsAnElementTypeViolation(): void + { + // `pick()` calls contains:: on a branch-merged Box receiver. Rock is not a + // Fruit, but the dropped bound means no check runs — it compiles and the call specializes. + $dist = $this->compileFixture('enclosing_param_bound_lenient_drop'); + + self::assertStringContainsString( + 'contains_', + self::read($dist, 'Use.php'), + 'the violating call was accepted and specialized (current lenient behaviour)', + ); + } + + public function testTheSameElementViolationIsCaughtWhenTheReceiverIsGroundable(): void + { + // Identical violation, but a straight-line Box receiver — E grounds to Fruit, so the + // bound IS checked and Rock is rejected. (Control for the lenient-drop limitation.) + try { + $this->compileInline([ + 'Models.xphp' => self::MODELS_LENIENT, + 'Box.xphp' => self::BOX_CONTAINS, + 'Use.xphp' => <<<'PHP' + (); + $box->contains::(new Rock()); + PHP, + ]); + self::fail('expected a bound violation for contains:: on Box'); + } catch (RuntimeException $e) { + self::assertStringContainsString('Generic bound violated', $e->getMessage()); + self::assertStringContainsString('extend/implement "App\\Fruit"', $e->getMessage()); + } + } + + // --- Limitation 2: compound `` is dropped WHOLE when E is ungroundable --- + + public function testCompoundBoundDropDiscardsTheCheckableNamedOperand(): void + { + // `store()` calls register:: on a branch-merged Box receiver. Banana satisfies + // the E half (Banana <: Fruit) but not the Named half — yet the whole bound is dropped, so + // the Named constraint is never enforced and it compiles. + $dist = $this->compileFixture('enclosing_param_bound_compound_drop'); + + self::assertStringContainsString( + 'register_', + self::read($dist, 'Use.php'), + 'the not-Named argument was accepted and specialized (current lenient behaviour)', + ); + } + + public function testTheNamedOperandIsEnforcedWhenTheReceiverIsGroundable(): void + { + // Identical call, but a straight-line Box receiver — E grounds to Fruit, the bound + // becomes `Named & Fruit`, and the intersection rejects Banana for not being Named. (Control + // for the compound-drop limitation: it proves the Named operand is the thing being lost.) + try { + $this->compileInline([ + 'Models.xphp' => self::MODELS_COMPOUND, + 'Box.xphp' => self::BOX_REGISTER, + 'Use.xphp' => <<<'PHP' + (); + $box->register::(new Banana()); + PHP, + ]); + self::fail('expected a bound violation for register:: on Box'); + } catch (RuntimeException $e) { + self::assertStringContainsString('Generic bound violated', $e->getMessage()); + self::assertStringContainsString('Named', $e->getMessage()); + } + } + + // --- inline sources shared by the controls (mirror the fixtures) --- + + private const MODELS_LENIENT = <<<'PHP' + { + public function contains(U $value): bool { return true; } + } + PHP; + + private const MODELS_COMPOUND = <<<'PHP' + { + public function register(U $value): void {} + } + PHP; + + // --- harness --- + + /** Compile a fixture's `source/` dir to a fresh temp dist; returns the dist path. */ + private function compileFixture(string $name): string + { + $src = realpath(__DIR__ . '/../../fixture/compile/' . $name . '/source') + ?: throw new RuntimeException("missing fixture: {$name}"); + $out = $this->freshWorkDir(); + $sources = (new NativeFileFinder())->find($src) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + $this->buildCompiler()->compile($sources, $src, $out . '/dist', $out . '/cache'); + + return $out . '/dist'; + } + + /** @param array $files filename => contents */ + private function compileInline(array $files): void + { + $out = $this->freshWorkDir(); + $src = $out . '/src'; + mkdir($src, 0o755, true); + foreach ($files as $name => $contents) { + file_put_contents($src . '/' . $name, $contents); + } + $sources = (new NativeFileFinder())->find($src) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + $this->buildCompiler()->compile($sources, $src, $out . '/dist', $out . '/cache'); + } + + private function freshWorkDir(): string + { + $dir = sys_get_temp_dir() . '/xphp-encbound-lim-' . uniqid('', true); + mkdir($dir, 0o755, true); + $this->workDirs[] = $dir; + + return $dir; + } + + private function buildCompiler(): Compiler + { + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + + return new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + } + + private static function read(string $dir, string $file): string + { + $path = $dir . '/' . $file; + + return is_file($path) ? (file_get_contents($path) ?: '') : ''; + } + + private static function rrmdir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . '/' . $entry; + is_dir($path) ? self::rrmdir($path) : unlink($path); + } + rmdir($dir); + } +} diff --git a/test/fixture/compile/enclosing_param_bound_compound_drop/source/Box.xphp b/test/fixture/compile/enclosing_param_bound_compound_drop/source/Box.xphp new file mode 100644 index 0000000..1df6f63 --- /dev/null +++ b/test/fixture/compile/enclosing_param_bound_compound_drop/source/Box.xphp @@ -0,0 +1,12 @@ + +{ + // U must be BOTH `Named` AND a subtype of the element type E. The `Named` half is checkable + // with no knowledge of E; only the `E` half needs the receiver's element type. + public function register(U $value): void {} +} diff --git a/test/fixture/compile/enclosing_param_bound_compound_drop/source/Models.xphp b/test/fixture/compile/enclosing_param_bound_compound_drop/source/Models.xphp new file mode 100644 index 0000000..c2eab04 --- /dev/null +++ b/test/fixture/compile/enclosing_param_bound_compound_drop/source/Models.xphp @@ -0,0 +1,13 @@ +, but +// the branch merge keeps only the class and drops the tracked element type, so E is ungroundable. +// The WHOLE `Named & E` bound is then dropped — including the `Named` operand, which could be +// enforced with no knowledge of E. `Banana` is a valid element (Banana <: Fruit) but is not +// `Named`, yet it is silently accepted. +// +// CURRENT behaviour: compiles (no bound violation). A v2 that evaluates the ungroundable operand as +// "unknown" instead of dropping the whole bound should still REJECT on the `Named` operand. +function store(bool $cond): void +{ + if ($cond) { + $box = new Box::(); + } else { + $box = new Box::(); + } + + $box->register::(new Banana()); +} diff --git a/test/fixture/compile/enclosing_param_bound_lenient_drop/source/Box.xphp b/test/fixture/compile/enclosing_param_bound_lenient_drop/source/Box.xphp new file mode 100644 index 0000000..d7fdb33 --- /dev/null +++ b/test/fixture/compile/enclosing_param_bound_lenient_drop/source/Box.xphp @@ -0,0 +1,15 @@ + +{ + // U must be a subtype of the element type E (the sound, element-typed shape on a + // covariant collection). The bound is checked only when E can be grounded. + public function contains(U $value): bool + { + return true; + } +} diff --git a/test/fixture/compile/enclosing_param_bound_lenient_drop/source/Models.xphp b/test/fixture/compile/enclosing_param_bound_lenient_drop/source/Models.xphp new file mode 100644 index 0000000..c39cc48 --- /dev/null +++ b/test/fixture/compile/enclosing_param_bound_lenient_drop/source/Models.xphp @@ -0,0 +1,11 @@ +, so a reader knows +// $box is Box — but the branch merge keeps only the class (Box), dropping the tracked +// element type. With E ungroundable the bound `` is dropped for this call, so `Rock` +// (plainly not a Fruit) is silently accepted. +// +// CURRENT behaviour: compiles (no bound violation). A v2 that grounds this case should REJECT it. +function pick(bool $cond): bool +{ + if ($cond) { + $box = new Box::(); + } else { + $box = new Box::(); + } + + return $box->contains::(new Rock()); +} From 56352a5e7a34399ee31f3537e68161900fffdcb4 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 23 Jun 2026 20:19:40 +0000 Subject: [PATCH 074/114] docs(adr): use the Fruit/Banana/Rock example vocabulary consistently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Product is a poor element type for a "not a subtype" example — nearly anything is a product. Switch the enclosing-param bound examples to the codebase's existing hierarchy convention (Banana extends Fruit), with Rock as the unambiguous non-subtype: a rock is plainly not a fruit. ADR-0018, the changelog entry, the type-bounds doc, and the integration test (Food <- Fruit <- Banana) now share one vocabulary. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 +- ...ric-bounds-on-enclosing-type-parameters.md | 16 ++-- docs/syntax/type-bounds.md | 10 +-- .../EnclosingParamBoundIntegrationTest.php | 80 +++++++++---------- 4 files changed, 55 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8e7d1a..4d5228d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Element-typed methods on covariant collections.** A method-level type parameter bounded by an enclosing class type parameter — `class Box<+E> { public function contains(U $value): bool }` — now has its bound **grounded** against the - receiver's concrete type argument: `Box::contains` is accepted when - `Book <: Product`, and a genuine violation (`Box::contains`) is rejected + receiver's concrete type argument: `Box::contains` is accepted when + `Banana <: Fruit`, and a genuine violation (`Box::contains`) is rejected with the bound shown as the real type, not `E`. The receiver's argument is threaded up the `extends`/`implements` chain, so a method declared on a generic interface/base and inherited by a concrete collection is grounded too. This is the sound, element-typed diff --git a/docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md b/docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md index bd96905..acab513 100644 --- a/docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md +++ b/docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md @@ -13,10 +13,10 @@ uses for the element-search methods on its covariant `ConstVector`). `U` is inva variance ([ADR-0014](0014-variance-markers-are-class-level-only.md)) — it only needs the enclosing `E` to be resolved. -But the bound `E` was evaluated against the literal type-parameter name: `isSubtype("Book", "E")` +But the bound `E` was evaluated against the literal type-parameter name: `isSubtype("Banana", "E")` treats `"E"` as a phantom class, always returns false, and rejects *valid* code -(`Box::contains` with `Book <: Product`) with a misleading -*"Book does not extend/implement E"*. Bound checking is otherwise nominal and erased +(`Box::contains` with `Banana <: Fruit`) with a misleading +*"Banana does not extend/implement E"*. Bound checking is otherwise nominal and erased ([ADR-0005](0005-nominal-erased-bound-checking.md)); the missing piece is grounding `E` to the receiver's concrete type argument before the check. @@ -35,9 +35,9 @@ Chosen: **at a method-generic call site, ground each bound that names an enclosi parameter against the receiver's concrete type arguments, then run the existing bound check.** - The receiver's type arguments are recovered from flow typing (a parameter's declared - `Box`, a `new Box::()` local, a `$this->prop` of declared generic type) and + `Box`, a `new Box::()` local, a `$this->prop` of declared generic type) and threaded up the parameterized `extends`/`implements` chain to the method's **declaring** class, so - a method inherited from `Collection<+E>` grounds against an `ArrayList` receiver. + a method inherited from `Collection<+E>` grounds against an `ArrayList` receiver. - A bound leaf that is still a bare type parameter after grounding — the argument couldn't be determined (an opaque/inherited-but-unparameterized receiver, a post-branch merged receiver, or a `$this` call inside the still-uninstantiated template body) — has its bound **dropped for that @@ -48,8 +48,8 @@ parameter against the receiver's concrete type arguments, then run the existing ### Consequences - Good: the one place a covariant collection degraded to `mixed` now has a sound, element-typed - parameter; `Box::contains` is accepted and `Box::contains` is - rejected with the bound shown **grounded** (`Book`), not `E`. + parameter; `Box::contains` is accepted and `Box::contains` is + rejected with the bound shown **grounded** (`Fruit`), not `E`. - Trade-off — **lenient drop is a deliberate loosening, not "always sound".** Where the receiver's argument can't be determined, an ungroundable bound goes from today's *hard reject* to a *silent accept*, so a genuine violation the compiler can't analyze is no longer caught. We accept this @@ -77,7 +77,7 @@ parameter against the receiver's concrete type arguments, then run the existing ### Confirmation The grounding, the inheritance threading, and the lenient fallbacks are covered end-to-end: a direct -and an **inherited** (`ArrayList extends Base<+E>`) accept, a multi-argument +and an **inherited** (`ArrayList extends Base<+E>`) accept, a multi-argument (`Pair::containsValue`) accept that grounds the right parameter, a reject whose message shows the grounded bound, the unbounded method generic unchanged, and the lenient cases (`$this` body, a parameter / property / closure-`use` receiver, and a branch-merge that drops diff --git a/docs/syntax/type-bounds.md b/docs/syntax/type-bounds.md index cdde2a7..5c2dec7 100644 --- a/docs/syntax/type-bounds.md +++ b/docs/syntax/type-bounds.md @@ -105,15 +105,15 @@ class Box<+E> { public function contains(U $value): bool { /* ... */ } } -$box = new Box::(); -$box->contains::(new Book()); // OK — Book is a subtype of Product +$box = new Box::(); +$box->contains::(new Banana()); // OK — Banana is a subtype of Fruit ``` At the call site the bound `E` is **grounded** to the receiver's -concrete type argument (`Product` for a `Box`), then checked +concrete type argument (`Fruit` for a `Box`), then checked like any other bound. A genuine violation -(`Box` then `->contains::(...)`) is rejected, with the -message naming the grounded type (`Book`). The receiver's argument is +(`Box` then `->contains::(...)`) is rejected, with the +message naming the grounded type (`Fruit`). The receiver's argument is threaded through `extends`/`implements`, so a method declared on a generic interface/base and inherited by a concrete collection grounds the same way. diff --git a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php index e28cef9..e776456 100644 --- a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php +++ b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php @@ -34,14 +34,14 @@ protected function tearDown(): void self::rrmdir($this->work); } - /** Shared model: Book extends Product extends Item. */ + /** Shared model: Banana extends Fruit extends Food. */ private const MODELS = <<<'PHP' (U $value): bool { return true; } (); - $box->contains::(new Book()); + $box = new Box::(); + $box->contains::(new Banana()); PHP, ]); @@ -73,7 +73,7 @@ public function contains(U $value): bool { return true; } public function testInheritedEnclosingParamBoundAcceptsSubtype(): void { // `contains` is declared on a generic BASE; the receiver is a subclass. Grounding must - // thread the receiver's `Product` through `extends Base` to the base's `E`. + // thread the receiver's `Fruit` through `extends Base` to the base's `E`. $this->compile([ 'Models.xphp' => self::MODELS, 'Base.xphp' => <<<'PHP' @@ -94,8 +94,8 @@ class ArrayList<+E> extends Base {} (); - $list->contains::(new Book()); + $list = new ArrayList::(); + $list->contains::(new Banana()); PHP, ]); @@ -105,7 +105,7 @@ class ArrayList<+E> extends Base {} public function testMultiArgEnclosingParamBoundGroundsTheRightParameter(): void { // `Pair::containsValue` — grounding must pick V (index 1), not K. If it used - // K (Item), `Book <: Item` would also pass, so make K a type Book is NOT a subtype of. + // K (Food), `Banana <: Food` would also pass, so make K a type Banana is NOT a subtype of. $this->compile([ 'Models.xphp' => self::MODELS, 'Key.xphp' => "(U $value): bool { return true; } (); - $pair->containsValue::(new Book()); + $pair = new Pair::(); + $pair->containsValue::(new Banana()); PHP, ]); @@ -131,8 +131,8 @@ public function containsValue(U $value): bool { return true; } public function testEnclosingParamBoundRejectsNonSubtypeWithGroundedMessage(): void { - // Box::contains — Product is NOT a subtype of Book, so this must reject, and - // the message must show the GROUNDED bound (`Book`), not the literal type parameter `E`. + // Box::contains — Fruit is NOT a subtype of Banana, so this must reject, and + // the message must show the GROUNDED bound (`Banana`), not the literal type parameter `E`. try { $this->compile([ 'Models.xphp' => self::MODELS, @@ -148,15 +148,15 @@ public function contains(U $value): bool { return true; } (); - $box->contains::(new Product()); + $box = new Box::(); + $box->contains::(new Fruit()); PHP, ]); - self::fail('Expected a bound violation for Box::contains.'); + self::fail('Expected a bound violation for Box::contains.'); } catch (RuntimeException $e) { $msg = $e->getMessage(); self::assertStringContainsString('Generic bound violated', $msg); - self::assertStringContainsString('extend/implement "App\\Book"', $msg, 'bound must be grounded to the receiver arg Book'); + self::assertStringContainsString('extend/implement "App\\Banana"', $msg, 'bound must be grounded to the receiver arg Banana'); self::assertStringNotContainsString('"E"', $msg, 'must not report the literal type parameter E'); } } @@ -177,8 +177,8 @@ public function pick(U $value): U { return $value; } (); - $box->pick::(new Book()); + $box = new Box::(); + $box->pick::(new Banana()); PHP, ]); @@ -187,7 +187,7 @@ public function pick(U $value): U { return $value; } public function testThisReceiverEnclosingBoundIsLenient(): void { - // `$this->contains::()` inside the template body: E has no concrete value yet, so the + // `$this->contains::()` inside the template body: E has no concrete value yet, so the // bound is dropped (lenient) rather than rejected against the phantom `E`. $this->compile([ 'Models.xphp' => self::MODELS, @@ -197,14 +197,14 @@ public function testThisReceiverEnclosingBoundIsLenient(): void namespace App; class Box<+E> { public function contains(U $value): bool { return true; } - public function probe(): bool { return $this->contains::(new Book()); } + public function probe(): bool { return $this->contains::(new Banana()); } } PHP, 'Use.xphp' => <<<'PHP' (); + $box = new Box::(); PHP, ]); @@ -213,10 +213,10 @@ public function probe(): bool { return $this->contains::(new Book()); } public function testParameterReceiverGroundsAndRejects(): void { - // A parameter typed `Box` must ground `contains` to Book; Product is not a + // A parameter typed `Box` must ground `contains` to Banana; Fruit is not a // subtype, so this rejects — proving the param's type args are tracked and grounded. $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('extend/implement "App\\Book"'); + $this->expectExceptionMessage('extend/implement "App\\Banana"'); $this->compile([ 'Models.xphp' => self::MODELS, 'Box.xphp' => self::box(), @@ -224,7 +224,7 @@ public function testParameterReceiverGroundsAndRejects(): void $b): bool { return $b->contains::(new Product()); } + function consume(Box $b): bool { return $b->contains::(new Fruit()); } PHP, ]); } @@ -232,7 +232,7 @@ function consume(Box $b): bool { return $b->contains::(new Produc public function testPropertyReceiverGroundsAndRejects(): void { $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('extend/implement "App\\Book"'); + $this->expectExceptionMessage('extend/implement "App\\Banana"'); $this->compile([ 'Models.xphp' => self::MODELS, 'Box.xphp' => self::box(), @@ -241,8 +241,8 @@ public function testPropertyReceiverGroundsAndRejects(): void declare(strict_types=1); namespace App; class Holder { - private Box $b; - public function run(): bool { return $this->b->contains::(new Product()); } + private Box $b; + public function run(): bool { return $this->b->contains::(new Fruit()); } } PHP, ]); @@ -252,7 +252,7 @@ public function testBranchMergeDropsArgsAndFallsBackToLenient(): void { // Both arms assign a Box but with DIFFERENT args; the FQN merges (still Box) yet the args // conflict, so they are dropped → the call is lenient. If the args were not dropped, the - // call would ground to one arm's type and wrongly reject `Item` (a supertype of both). + // call would ground to one arm's type and wrongly reject `Food` (a supertype of both). $this->compile([ 'Models.xphp' => self::MODELS, 'Box.xphp' => self::box(), @@ -261,8 +261,8 @@ public function testBranchMergeDropsArgsAndFallsBackToLenient(): void declare(strict_types=1); namespace App; function pick(bool $c): bool { - if ($c) { $box = new Box::(); } else { $box = new Box::(); } - return $box->contains::(new Item()); + if ($c) { $box = new Box::(); } else { $box = new Box::(); } + return $box->contains::(new Food()); } PHP, ]); @@ -273,9 +273,9 @@ function pick(bool $c): bool { public function testArgsSurviveABranchThatDoesNotTouchTheReceiver(): void { // The receiver is set before the branch and never reassigned inside it, so its args survive - // and ground the call — Product is not a subtype of Book, so it still rejects. + // and ground the call — Fruit is not a subtype of Banana, so it still rejects. $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('extend/implement "App\\Book"'); + $this->expectExceptionMessage('extend/implement "App\\Banana"'); $this->compile([ 'Models.xphp' => self::MODELS, 'Box.xphp' => self::box(), @@ -284,9 +284,9 @@ public function testArgsSurviveABranchThatDoesNotTouchTheReceiver(): void declare(strict_types=1); namespace App; function keep(bool $c): bool { - $box = new Box::(); + $box = new Box::(); if ($c) { $unrelated = 1; } - return $box->contains::(new Product()); + return $box->contains::(new Fruit()); } PHP, ]); @@ -295,9 +295,9 @@ function keep(bool $c): bool { public function testClosureUseReceiverGroundsAndRejects(): void { // `use ($box)` imports the outer local's args into the closure scope, so the bound grounds - // to Book inside the closure body and rejects Product. + // to Banana inside the closure body and rejects Fruit. $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('extend/implement "App\\Book"'); + $this->expectExceptionMessage('extend/implement "App\\Banana"'); $this->compile([ 'Models.xphp' => self::MODELS, 'Box.xphp' => self::box(), @@ -306,8 +306,8 @@ public function testClosureUseReceiverGroundsAndRejects(): void declare(strict_types=1); namespace App; function viaClosure(): callable { - $box = new Box::(); - return function () use ($box): bool { return $box->contains::(new Product()); }; + $box = new Box::(); + return function () use ($box): bool { return $box->contains::(new Fruit()); }; } PHP, ]); From 93ece6a0de585b537c842b776112eea84a1a5ef6 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 23 Jun 2026 20:25:37 +0000 Subject: [PATCH 075/114] docs: use \Stringable instead of an invented "Named" marker in the compound-bound example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Named" is a vague invented marker — its contract isn't obvious. The compound-bound example only needs a checkable operand independent of the element type, so use PHP's built-in \Stringable (has __toString), which the codebase already uses as a bound and which every PHP developer understands. The bound is now ``: U must be a subtype of the element type AND stringable. Banana satisfies the element half (Banana <: Fruit) but not \Stringable, so it's the operand that gets masked when the whole bound is dropped. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ric-bounds-on-enclosing-type-parameters.md | 4 ++-- .../EnclosingParamBoundLimitationTest.php | 21 +++++++++---------- .../source/Box.xphp | 7 ++++--- .../source/Models.xphp | 6 ++---- .../source/Use.xphp | 8 +++---- 5 files changed, 22 insertions(+), 24 deletions(-) diff --git a/docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md b/docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md index acab513..b3d658d 100644 --- a/docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md +++ b/docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md @@ -57,8 +57,8 @@ parameter against the receiver's concrete type arguments, then run the existing a blanket warning is noisy (a branch-merged receiver routinely drops its arguments). A *targeted* `xphp check` diagnostic for the narrow "receiver known, arity correct, still ungroundable" case is possible future work. -- Trade-off — **compound bounds drop whole.** A method bound like `` whose `E` can't - be grounded drops the entire bound, including the checkable `Named` operand. Narrow (it needs a +- Trade-off — **compound bounds drop whole.** A method bound like `` whose `E` + can't be grounded drops the entire bound, including the checkable `\Stringable` operand. Narrow (it needs a concrete operand intersected with an ungroundable enclosing parameter) and an extension of the lenient-drop decision; a future refinement could drop only the ungrounded operand. - Scope — **static methods are out.** A class type parameter is unbound in a static context, so a diff --git a/test/Transpiler/Monomorphize/EnclosingParamBoundLimitationTest.php b/test/Transpiler/Monomorphize/EnclosingParamBoundLimitationTest.php index c635a38..95c513c 100644 --- a/test/Transpiler/Monomorphize/EnclosingParamBoundLimitationTest.php +++ b/test/Transpiler/Monomorphize/EnclosingParamBoundLimitationTest.php @@ -73,27 +73,27 @@ public function testTheSameElementViolationIsCaughtWhenTheReceiverIsGroundable() } } - // --- Limitation 2: compound `` is dropped WHOLE when E is ungroundable --- + // --- Limitation 2: compound `` is dropped WHOLE when E is ungroundable --- - public function testCompoundBoundDropDiscardsTheCheckableNamedOperand(): void + public function testCompoundBoundDropDiscardsTheCheckableStringableOperand(): void { // `store()` calls register:: on a branch-merged Box receiver. Banana satisfies - // the E half (Banana <: Fruit) but not the Named half — yet the whole bound is dropped, so - // the Named constraint is never enforced and it compiles. + // the E half (Banana <: Fruit) but not the \Stringable half — yet the whole bound is dropped, + // so the \Stringable constraint is never enforced and it compiles. $dist = $this->compileFixture('enclosing_param_bound_compound_drop'); self::assertStringContainsString( 'register_', self::read($dist, 'Use.php'), - 'the not-Named argument was accepted and specialized (current lenient behaviour)', + 'the non-Stringable argument was accepted and specialized (current lenient behaviour)', ); } - public function testTheNamedOperandIsEnforcedWhenTheReceiverIsGroundable(): void + public function testTheStringableOperandIsEnforcedWhenTheReceiverIsGroundable(): void { // Identical call, but a straight-line Box receiver — E grounds to Fruit, the bound - // becomes `Named & Fruit`, and the intersection rejects Banana for not being Named. (Control - // for the compound-drop limitation: it proves the Named operand is the thing being lost.) + // becomes `\Stringable & Fruit`, and the intersection rejects Banana for not being Stringable. + // (Control for the compound-drop limitation: it proves the \Stringable operand is what's lost.) try { $this->compileInline([ 'Models.xphp' => self::MODELS_COMPOUND, @@ -109,7 +109,7 @@ public function testTheNamedOperandIsEnforcedWhenTheReceiverIsGroundable(): void self::fail('expected a bound violation for register:: on Box'); } catch (RuntimeException $e) { self::assertStringContainsString('Generic bound violated', $e->getMessage()); - self::assertStringContainsString('Named', $e->getMessage()); + self::assertStringContainsString('Stringable', $e->getMessage()); } } @@ -136,7 +136,6 @@ public function contains(U $value): bool { return true; } { - public function register(U $value): void {} + public function register(U $value): void {} } PHP; diff --git a/test/fixture/compile/enclosing_param_bound_compound_drop/source/Box.xphp b/test/fixture/compile/enclosing_param_bound_compound_drop/source/Box.xphp index 1df6f63..019da97 100644 --- a/test/fixture/compile/enclosing_param_bound_compound_drop/source/Box.xphp +++ b/test/fixture/compile/enclosing_param_bound_compound_drop/source/Box.xphp @@ -6,7 +6,8 @@ namespace App; class Box<+E> { - // U must be BOTH `Named` AND a subtype of the element type E. The `Named` half is checkable - // with no knowledge of E; only the `E` half needs the receiver's element type. - public function register(U $value): void {} + // U must be BOTH `\Stringable` (have a `__toString`) AND a subtype of the element type E. The + // `\Stringable` half is checkable with no knowledge of E; only the `E` half needs the receiver's + // element type. + public function register(U $value): void {} } diff --git a/test/fixture/compile/enclosing_param_bound_compound_drop/source/Models.xphp b/test/fixture/compile/enclosing_param_bound_compound_drop/source/Models.xphp index c2eab04..c026038 100644 --- a/test/fixture/compile/enclosing_param_bound_compound_drop/source/Models.xphp +++ b/test/fixture/compile/enclosing_param_bound_compound_drop/source/Models.xphp @@ -4,10 +4,8 @@ declare(strict_types=1); namespace App; -interface Named {} - class Fruit {} -// Banana is a valid element (Banana <: Fruit) but does NOT implement Named — so it satisfies the -// `E` half of a `Named & E` bound while violating the `Named` half. +// Banana is a valid element (Banana <: Fruit) but is NOT `\Stringable` (it has no `__toString`) — so +// it satisfies the `E` half of a `\Stringable & E` bound while violating the `\Stringable` half. class Banana extends Fruit {} diff --git a/test/fixture/compile/enclosing_param_bound_compound_drop/source/Use.xphp b/test/fixture/compile/enclosing_param_bound_compound_drop/source/Use.xphp index fe144e6..c63425f 100644 --- a/test/fixture/compile/enclosing_param_bound_compound_drop/source/Use.xphp +++ b/test/fixture/compile/enclosing_param_bound_compound_drop/source/Use.xphp @@ -6,12 +6,12 @@ namespace App; // Known limitation (ADR-0018, "compound bounds drop whole"): both arms construct Box, but // the branch merge keeps only the class and drops the tracked element type, so E is ungroundable. -// The WHOLE `Named & E` bound is then dropped — including the `Named` operand, which could be -// enforced with no knowledge of E. `Banana` is a valid element (Banana <: Fruit) but is not -// `Named`, yet it is silently accepted. +// The WHOLE `\Stringable & E` bound is then dropped — including the `\Stringable` operand, which +// could be enforced with no knowledge of E. `Banana` is a valid element (Banana <: Fruit) but is +// not `\Stringable`, yet it is silently accepted. // // CURRENT behaviour: compiles (no bound violation). A v2 that evaluates the ungroundable operand as -// "unknown" instead of dropping the whole bound should still REJECT on the `Named` operand. +// "unknown" instead of dropping the whole bound should still REJECT on the `\Stringable` operand. function store(bool $cond): void { if ($cond) { From 950eb1e1a1cef34424f530cee43a05a9d5377622 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 23 Jun 2026 23:14:16 +0000 Subject: [PATCH 076/114] fix(monomorphize): check a class bound that references a sibling type parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A class-level bound that references a sibling type parameter — `class Pair` — was wrongly rejected when instantiated, because `U`'s bound was evaluated as the literal `T` (no class) and `isSubtype("Banana","T")` is false, so a valid `Pair` (Banana a subtype of Fruit) failed the build. Ground each parameter's bound against the supplied (padded) arguments before the bound check, so the sibling reference resolves to the concrete argument; a genuine violation (`Pair`) still rejects, with the grounded bound in the message. A default whose bound references a sibling parameter can't be validated at declaration time (the sibling is abstract there); defer it to the instantiation check, which already pads the default and now grounds the bound. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/Registry.php | 61 +++++- .../Monomorphize/RegistrySiblingBoundTest.php | 179 ++++++++++++++++++ 2 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 test/Transpiler/Monomorphize/RegistrySiblingBoundTest.php diff --git a/src/Transpiler/Monomorphize/Registry.php b/src/Transpiler/Monomorphize/Registry.php index 85b8ed9..5e8fe8c 100644 --- a/src/Transpiler/Monomorphize/Registry.php +++ b/src/Transpiler/Monomorphize/Registry.php @@ -315,6 +315,15 @@ public function validateDefaultsAgainstBounds(): void if (!$param->default->isConcrete()) { continue; } + // A bound that references a sibling type parameter (`U : T`) can't be validated at + // declaration time -- the sibling is abstract here. It is grounded and checked at + // instantiation (validateBounds substitutes the concrete sibling arg), so defer it. + // (A template that is never instantiated therefore never checks such a default; that + // is inherent -- you can't prove `default <: T` without a concrete `T` -- and admits + // no unsafe instantiation, since every actual use is checked.) + if (self::boundReferencesSiblingParam($param->bound)) { + continue; + } $verdict = self::evaluateBound( $param->bound, $param->default, @@ -516,7 +525,7 @@ private function validateBounds(string $templateFqn, array $args, ?SourceLocatio return; } self::checkBounds( - $definition->typeParams, + self::groundSiblingBounds($definition->typeParams, $args), $args, $this->hierarchy, self::formatInstantiation(ltrim($templateFqn, '\\'), $args), @@ -749,6 +758,56 @@ public static function substituteBound(BoundExpr $bound, array $subst): BoundExp return $bound; } + /** + * Ground each type parameter's bound against the supplied (padded) args, so a bound that + * references a sibling parameter (`class Pair`) is checked against the concrete arg, + * not the literal parameter name. Counts must match -- an arity mismatch is reported upstream + * (padArgsWithDefaults) and is left ungrounded so checkBounds early-returns on it. + * + * @param list $params + * @param list $args + * @return list + */ + private static function groundSiblingBounds(array $params, array $args): array + { + if (count($params) !== count($args)) { + return $params; + } + $subst = []; + foreach ($params as $i => $param) { + $subst[$param->name] = $args[$i]; + } + + return array_map( + static fn (TypeParam $p): TypeParam => $p->bound === null + ? $p + : new TypeParam($p->name, self::substituteBound($p->bound, $subst), $p->default, $p->variance), + $params, + ); + } + + /** + * Whether the bound has a top-level leaf that is a bare type parameter (a sibling reference like + * `U : T`) -- which can only be checked once that parameter is bound. A leaf naming a real class + * with type-param ARGS (an F-bound `T : Comparable`) is NOT such a reference: it is checked + * erased on the leaf name, so it stays a declaration-time check. + */ + private static function boundReferencesSiblingParam(BoundExpr $bound): bool + { + if ($bound instanceof BoundLeaf) { + return $bound->type->isTypeParam; + } + if ($bound instanceof BoundIntersection || $bound instanceof BoundUnion) { + foreach ($bound->operands as $operand) { + if (self::boundReferencesSiblingParam($operand)) { + return true; + } + } + } + + return false; + } + /** * Three-way verdict (true / false / null) for a bound expression against a * concrete TypeRef. Walks the BoundExpr tree: diff --git a/test/Transpiler/Monomorphize/RegistrySiblingBoundTest.php b/test/Transpiler/Monomorphize/RegistrySiblingBoundTest.php new file mode 100644 index 0000000..72b1437 --- /dev/null +++ b/test/Transpiler/Monomorphize/RegistrySiblingBoundTest.php @@ -0,0 +1,179 @@ +`) must be + * grounded against the supplied arg before checking — otherwise `U`'s bound is the literal `T`, + * which is no class, so `isSubtype("Banana","T")` is false and a valid `Pair` is + * wrongly rejected. Banana <: Fruit throughout. + */ +final class RegistrySiblingBoundTest extends TestCase +{ + /** Hierarchy: Banana <: Fruit (and Stringable); Cherry <: Fruit (not Stringable); Money <: Comparable; Apple unrelated. */ + private static function hierarchy(): TypeHierarchy + { + return new TypeHierarchy([ + 'App\\Banana' => ['App\\Fruit', 'Stringable'], + 'App\\Cherry' => ['App\\Fruit'], + 'App\\Fruit' => [], + 'App\\Apple' => [], + 'App\\Comparable' => [], + 'App\\Money' => ['App\\Comparable'], + ]); + } + + /** @param list $params */ + private static function registryFor(array $params): Registry + { + $registry = new Registry(hierarchy: self::hierarchy()); + $registry->recordDefinition('App\\Pair', 'Pair', $params, new Class_(new Identifier('Pair')), '/Pair.xphp'); + + return $registry; + } + + /** + * `class Pair` + * + * @return list + */ + private static function pairTU(): array + { + return [ + new TypeParam('T'), + new TypeParam('U', new BoundLeaf(new TypeRef('T', isTypeParam: true))), + ]; + } + + public function testSiblingBoundAcceptsWhenArgSatisfiesTheSiblingArg(): void + { + $registry = self::registryFor(self::pairTU()); + + // U = Banana must satisfy T = Fruit; Banana <: Fruit, so this is accepted (no throw). + $inst = $registry->recordInstantiation('App\\Pair', [new TypeRef('App\\Fruit'), new TypeRef('App\\Banana')]); + + self::assertSame('App\\Pair', $inst->templateFqn); + } + + public function testSiblingBoundRejectsWhenArgViolatesTheSiblingArg(): void + { + $registry = self::registryFor(self::pairTU()); + + // U = Fruit must satisfy T = Banana; Fruit is not a subtype of Banana → reject, and the + // message must show the GROUNDED bound (Banana), not the literal `T`. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Generic bound violated'); + $this->expectExceptionMessage('extend/implement "App\\Banana"'); + $registry->recordInstantiation('App\\Pair', [new TypeRef('App\\Banana'), new TypeRef('App\\Fruit')]); + } + + public function testDeclTimeDefaultCheckDefersASiblingParamBound(): void + { + // `class Pair`: the default (Banana) is concrete but the bound references + // a sibling (T), so it can't be validated at declaration time — it must defer (not throw). + $registry = self::registryFor([ + new TypeParam('T'), + new TypeParam('U', new BoundLeaf(new TypeRef('T', isTypeParam: true)), new TypeRef('App\\Banana')), + ]); + + $registry->validateDefaultsAgainstBounds(); // must not throw (deferred to instantiation) + + $this->addToAssertionCount(1); + } + + public function testDeclTimeDefaultCheckContinuesPastADeferredSiblingBound(): void + { + // A deferred sibling-bound param (B : A) must only SKIP itself, not stop the loop — a later + // param with a concrete bound whose default violates it (C : Fruit = Apple) must still be + // caught at declaration time. + $registry = self::registryFor([ + new TypeParam('A'), + new TypeParam('B', new BoundLeaf(new TypeRef('A', isTypeParam: true)), new TypeRef('App\\Banana')), + new TypeParam('C', new BoundLeaf(new TypeRef('App\\Fruit')), new TypeRef('App\\Apple')), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('App\\Fruit'); + $registry->validateDefaultsAgainstBounds(); + } + + public function testDefaultedSiblingBoundIsCheckedAtInstantiation(): void + { + // Same class; instantiating `Pair` pads U to its default Banana → Banana must satisfy + // T = Fruit → Banana <: Fruit → accepted. + $registry = self::registryFor([ + new TypeParam('T'), + new TypeParam('U', new BoundLeaf(new TypeRef('T', isTypeParam: true)), new TypeRef('App\\Banana')), + ]); + + $inst = $registry->recordInstantiation('App\\Pair', [new TypeRef('App\\Fruit')]); + + self::assertSame('App\\Pair', $inst->templateFqn); + } + + public function testDefaultedSiblingBoundRejectsAtInstantiationWhenTheDefaultViolates(): void + { + // `Pair` pads U to its default Banana → Banana must satisfy T = Apple → Banana is not + // a subtype of Apple → rejected at instantiation (the deferred check fires here). + $registry = self::registryFor([ + new TypeParam('T'), + new TypeParam('U', new BoundLeaf(new TypeRef('T', isTypeParam: true)), new TypeRef('App\\Banana')), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('extend/implement "App\\Apple"'); + $registry->recordInstantiation('App\\Pair', [new TypeRef('App\\Apple')]); + } + + public function testBoundReferencingALaterSiblingIsGrounded(): void + { + // `class Rev` — T's bound references a LATER param. The subst map is built over all + // params first, so it grounds regardless of order. Banana <: Fruit → accepted. + $registry = self::registryFor([ + new TypeParam('T', new BoundLeaf(new TypeRef('U', isTypeParam: true))), + new TypeParam('U'), + ]); + + $inst = $registry->recordInstantiation('App\\Pair', [new TypeRef('App\\Banana'), new TypeRef('App\\Fruit')]); + + self::assertSame('App\\Pair', $inst->templateFqn); + } + + public function testIntersectionSiblingBoundGroundsTheSiblingOperand(): void + { + // `class Box2` — the sibling leaf lives inside an intersection. + // U = Cherry must satisfy (T = Banana) AND Stringable; Cherry is not a subtype of Banana → + // reject, with the grounded sibling operand (Banana) shown. + $registry = self::registryFor([ + new TypeParam('T'), + new TypeParam('U', new BoundIntersection( + new BoundLeaf(new TypeRef('T', isTypeParam: true)), + new BoundLeaf(new TypeRef('Stringable')), + )), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('App\\Banana'); + $registry->recordInstantiation('App\\Pair', [new TypeRef('App\\Banana'), new TypeRef('App\\Cherry')]); + } + + public function testFBoundIsPreservedWhenGroundedAtInstantiation(): void + { + // `class Sortable>` — grounding rewrites the inner arg `T`→Money, but the + // leaf name stays `Comparable` and is checked erased. Money <: Comparable → accepted. + $registry = self::registryFor([ + new TypeParam('T', new BoundLeaf(new TypeRef('App\\Comparable', [new TypeRef('T', isTypeParam: true)]))), + ]); + + $inst = $registry->recordInstantiation('App\\Pair', [new TypeRef('App\\Money')]); + + self::assertSame('App\\Pair', $inst->templateFqn); + } +} From d20cc7a101ade0cd2bbc6c3163a215438957062f Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 23 Jun 2026 23:28:10 +0000 Subject: [PATCH 077/114] feat(monomorphize): ground a branch-merged receiver when every arm agrees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A method-generic bound that references an enclosing class type parameter (`class Box<+E> { contains(U $value): bool }`) is grounded against the receiver's concrete type argument and checked at compile time. Until now the branch-flow analyzer dropped a receiver's type arguments on any if/else merge, so a receiver assigned inside both arms fell back to an unchecked bound — even when both arms agreed on the same parameterized type and the element type was plainly knowable. Keep the type arguments across a merge when every reachable arm assigned the SAME parameterized type, compared structurally by canonical form. A receiver whose arms disagree (`Box` vs `Box`) still drops its arguments and stays undeterminable. The merge is gated on the class itself having merged, so arguments are retained only for a fully-agreed receiver — a knowable type is never dropped, so a determinate bound violation is never silently accepted. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/GenericMethodCompiler.php | 93 +++++++++++++++++-- .../EnclosingParamBoundIntegrationTest.php | 44 +++++++++ .../EnclosingParamBoundLimitationTest.php | 63 +++++++------ .../source/Use.xphp | 13 ++- .../source/Use.xphp | 10 +- 5 files changed, 174 insertions(+), 49 deletions(-) diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 9342671..5f0de35 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -466,7 +466,7 @@ private function rewriteCallSites( * Branch snapshots are nested per-scope so that branches inside a closure * don't leak to branches in the enclosing function. * - * @var list, locals: array, paramArgs: array>, localArgs: array>, branches: list, localArgsSnapshot: array>, assigned: array, perBranchTypes: list>, armIndex: int}>}> + * @var list, locals: array, paramArgs: array>, localArgs: array>, branches: list, localArgsSnapshot: array>, assigned: array, perBranchTypes: list>, perBranchArgs: list>>, armIndex: int}>}> */ private array $scopeSnapshots = []; /** @@ -505,7 +505,7 @@ private function rewriteCallSites( * keeps `$x` instead of invalidating. See `P5.1-same-class-merge.md` * and the `computeMergedTypes` / `canMergeOnLeave` helpers below. * - * @var list, localArgsSnapshot: array>, assigned: array, perBranchTypes: list>, armIndex: int}> + * @var list, localArgsSnapshot: array>, assigned: array, perBranchTypes: list>, perBranchArgs: list>>, armIndex: int}> */ private array $branchSnapshots = []; @@ -661,6 +661,7 @@ public function enterNode(Node $node): null 'localArgsSnapshot' => $this->currentScopeLocalTypeArgs, 'assigned' => [], 'perBranchTypes' => [], + 'perBranchArgs' => [], // If_'s body is the first arm (armIndex=0). // Switch_ / Match_ have no parent body arm; the first // sibling enter promotes armIndex from -1 to 0. @@ -681,6 +682,11 @@ public function enterNode(Node $node): null $this->branchSnapshots[$top]['assigned'], $this->currentScopeLocalTypes, ); + $this->branchSnapshots[$top]['perBranchArgs'][] = + self::captureArmArgs( + $this->branchSnapshots[$top]['assigned'], + $this->currentScopeLocalTypeArgs, + ); } $this->branchSnapshots[$top]['armIndex']++; $this->currentScopeLocalTypes = $this->branchSnapshots[$top]['snapshot']; @@ -796,6 +802,10 @@ public function leaveNode(Node $node): ?Node $popped['assigned'], $this->currentScopeLocalTypes, ); + $popped['perBranchArgs'][] = self::captureArmArgs( + $popped['assigned'], + $this->currentScopeLocalTypeArgs, + ); } // Restore to pre-branch state. @@ -806,6 +816,7 @@ public function leaveNode(Node $node): ?Node // every reachable arm assigned the same FQN, instead // of unconditionally invalidating below. $merged = self::computeMergedTypes($node, $popped); + $mergedArgs = self::computeMergedArgs($node, $popped); foreach ($popped['assigned'] as $assignedName => $_true) { if (isset($merged[$assignedName])) { @@ -813,11 +824,14 @@ public function leaveNode(Node $node): ?Node } else { unset($this->currentScopeLocalTypes[$assignedName]); } - // Args are NOT merged across arms: even when the FQN agrees, the arms may - // have passed different type arguments (Box vs Box), so an - // assigned var always loses its args → the receiver falls to lenient - // grounding rather than risking a stale/ambiguous argument. - unset($this->currentScopeLocalTypeArgs[$assignedName]); + // Keep the receiver's type args only when the class merged AND every arm + // agreed on the args (by canonical()); a differing-arm receiver + // (Box vs Box) drops its args and stays undeterminable. + if (isset($merged[$assignedName], $mergedArgs[$assignedName])) { + $this->currentScopeLocalTypeArgs[$assignedName] = $mergedArgs[$assignedName]; + } else { + unset($this->currentScopeLocalTypeArgs[$assignedName]); + } if ($this->branchSnapshots !== []) { $parentTop = count($this->branchSnapshots) - 1; $this->branchSnapshots[$parentTop]['assigned'][$assignedName] = true; @@ -881,6 +895,23 @@ private static function captureArmTypes(array $assigned, array $currentTypes): a return $out; } + /** + * Per-arm receiver type args for the `assigned` set, parallel to captureArmTypes. + * `null` means "this arm did not finish with tracked args for that name". + * + * @param array $assigned + * @param array> $currentArgs + * @return array> + */ + private static function captureArmArgs(array $assigned, array $currentArgs): array + { + $out = []; + foreach ($assigned as $name => $_true) { + $out[$name] = $currentArgs[$name] ?? null; + } + return $out; + } + /** * Same-class merge eligibility: only the all-arms-reachable * branching parents can participate. Loops always have an @@ -981,6 +1012,54 @@ private static function computeMergedTypes(Node $node, array $popped): array return $merged; } + /** + * Same as computeMergedTypes, but for the receiver type args: name -> args for every var + * whose every reachable arm ended with the SAME args (compared by `TypeRef::canonical()`). + * Empty when the merge isn't allowed or an arm is missing args for the name. The caller + * additionally gates on the FQN having merged, so args are kept only for a fully-agreed + * receiver. + * + * @param array{perBranchArgs: list>>, assigned: array} $popped + * @return array> + */ + private static function computeMergedArgs(Node $node, array $popped): array + { + if (!self::canMergeOnLeave($node)) { + return []; + } + if (count($popped['perBranchArgs']) !== self::expectedArmCount($node)) { + return []; + } + $merged = []; + foreach ($popped['assigned'] as $name => $_true) { + $firstKey = null; + /** @var list|null $firstArgs */ + $firstArgs = null; + $allAgree = true; + foreach ($popped['perBranchArgs'] as $i => $armArgs) { + $args = $armArgs[$name] ?? null; + if ($args === null) { + $allAgree = false; + break; + } + $key = implode(',', array_map(static fn (TypeRef $a): string => $a->canonical(), $args)); + if ($i === 0) { + $firstKey = $key; + $firstArgs = $args; + continue; + } + if ($key !== $firstKey) { + $allAgree = false; + break; + } + } + if ($allAgree && $firstArgs !== null) { + $merged[$name] = $firstArgs; + } + } + return $merged; + } + private function rewriteStaticCall(StaticCall $node): ?Node { $args = $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); diff --git a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php index e776456..fa17a2c 100644 --- a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php +++ b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php @@ -270,6 +270,50 @@ function pick(bool $c): bool { $this->addToAssertionCount(1); } + public function testBranchMergeAgreementGroundsAndAcceptsSubtype(): void + { + // Both arms assign Box — the arms AGREE, so the merge keeps the element type and the + // receiver is determined to be Box. The bound grounds to Fruit and `Banana` (a Fruit) + // is accepted and the call specialized. + $dist = $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::box(), + 'Use.xphp' => <<<'PHP' + (); } else { $box = new Box::(); } + return $box->contains::(new Banana()); + } + PHP, + ]); + + self::assertStringContainsString('contains_', self::read($dist, 'Use.php')); + } + + public function testBranchMergeAgreementGroundsAndRejectsNonSubtype(): void + { + // Both arms assign Box — the arms agree, so the args survive the merge and ground the + // call to Banana. `Fruit` is not a subtype of Banana, so the determined receiver rejects it + // (a knowable type is never dropped → a determinate violation is never silently accepted). + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('extend/implement "App\\Banana"'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::box(), + 'Use.xphp' => <<<'PHP' + (); } else { $box = new Box::(); } + return $box->contains::(new Fruit()); + } + PHP, + ]); + } + public function testArgsSurviveABranchThatDoesNotTouchTheReceiver(): void { // The receiver is set before the branch and never reassigned inside it, so its args survive diff --git a/test/Transpiler/Monomorphize/EnclosingParamBoundLimitationTest.php b/test/Transpiler/Monomorphize/EnclosingParamBoundLimitationTest.php index 95c513c..bdc7fd3 100644 --- a/test/Transpiler/Monomorphize/EnclosingParamBoundLimitationTest.php +++ b/test/Transpiler/Monomorphize/EnclosingParamBoundLimitationTest.php @@ -13,14 +13,15 @@ use XPHP\FileSystem\FileWriter\NativeFileWriter; /** - * Pins the two documented limitations of enclosing-type-parameter bound grounding (ADR-0018) at - * their CURRENT behaviour: when the receiver's element type can't be grounded, a bound that - * references it is dropped and a real violation is silently accepted. + * Pins enclosing-type-parameter bound grounding when the receiver's element type is recovered from a + * branch merge: a receiver assigned to the SAME parameterised type in every arm of an if/else is + * determined, the bound that references its element type is grounded, and a real violation is + * rejected at compile time — exactly as it is for a straight-line receiver. * - * Each limitation is paired with a "groundable" control that compiles the SAME violation with a - * receiver whose element type IS known — and is correctly rejected. The pair proves the masking is - * real (not an unrelated no-op), and gives a future v2 a red→green target: the lenient assertions - * here are the ones that should flip to rejections once the limitations are addressed. + * Each branch-merge case is paired with a "groundable" control that compiles the SAME violation with + * a straight-line receiver whose element type is known. The pair proves the branch-merge path grounds + * to the same result as the direct path, honouring the project's maximum-runtime-safety principle: + * a knowable type is never dropped, so a determinate violation is never silently accepted. */ final class EnclosingParamBoundLimitationTest extends TestCase { @@ -35,19 +36,20 @@ protected function tearDown(): void $this->workDirs = []; } - // --- Limitation 1: bare `` bound is dropped when E is ungroundable --- + // --- Branch-merge agreement: a determinable receiver IS grounded and checked (bare ``) --- - public function testLenientDropAcceptsAnElementTypeViolation(): void + public function testBranchMergedReceiverIsGroundedAndRejectsAnElementViolation(): void { - // `pick()` calls contains:: on a branch-merged Box receiver. Rock is not a - // Fruit, but the dropped bound means no check runs — it compiles and the call specializes. - $dist = $this->compileFixture('enclosing_param_bound_lenient_drop'); - - self::assertStringContainsString( - 'contains_', - self::read($dist, 'Use.php'), - 'the violating call was accepted and specialized (current lenient behaviour)', - ); + // `pick()` calls contains:: on a Box receiver assigned in BOTH arms of an + // if/else. The arms agree, so the element type is determined (branch-merge agreement), the + // bound grounds to Fruit, and Rock — not a Fruit — is rejected at compile time. + try { + $this->compileFixture('enclosing_param_bound_lenient_drop'); + self::fail('expected a bound violation for contains:: on a branch-merged Box'); + } catch (RuntimeException $e) { + self::assertStringContainsString('Generic bound violated', $e->getMessage()); + self::assertStringContainsString('extend/implement "App\\Fruit"', $e->getMessage()); + } } public function testTheSameElementViolationIsCaughtWhenTheReceiverIsGroundable(): void @@ -73,20 +75,21 @@ public function testTheSameElementViolationIsCaughtWhenTheReceiverIsGroundable() } } - // --- Limitation 2: compound `` is dropped WHOLE when E is ungroundable --- + // --- Branch-merge agreement: a compound `` is grounded WHOLE and checked --- - public function testCompoundBoundDropDiscardsTheCheckableStringableOperand(): void + public function testBranchMergedReceiverIsGroundedAndRejectsACompoundViolation(): void { - // `store()` calls register:: on a branch-merged Box receiver. Banana satisfies - // the E half (Banana <: Fruit) but not the \Stringable half — yet the whole bound is dropped, - // so the \Stringable constraint is never enforced and it compiles. - $dist = $this->compileFixture('enclosing_param_bound_compound_drop'); - - self::assertStringContainsString( - 'register_', - self::read($dist, 'Use.php'), - 'the non-Stringable argument was accepted and specialized (current lenient behaviour)', - ); + // `store()` calls register:: on a Box receiver assigned in BOTH arms of an + // if/else. The arms agree, so the element type is determined, the bound grounds to + // `\Stringable & Fruit`, and Banana — a Fruit but not \Stringable — is rejected on the + // \Stringable operand. + try { + $this->compileFixture('enclosing_param_bound_compound_drop'); + self::fail('expected a bound violation for register:: on a branch-merged Box'); + } catch (RuntimeException $e) { + self::assertStringContainsString('Generic bound violated', $e->getMessage()); + self::assertStringContainsString('Stringable', $e->getMessage()); + } } public function testTheStringableOperandIsEnforcedWhenTheReceiverIsGroundable(): void diff --git a/test/fixture/compile/enclosing_param_bound_compound_drop/source/Use.xphp b/test/fixture/compile/enclosing_param_bound_compound_drop/source/Use.xphp index c63425f..7f9dd33 100644 --- a/test/fixture/compile/enclosing_param_bound_compound_drop/source/Use.xphp +++ b/test/fixture/compile/enclosing_param_bound_compound_drop/source/Use.xphp @@ -4,14 +4,13 @@ declare(strict_types=1); namespace App; -// Known limitation (ADR-0018, "compound bounds drop whole"): both arms construct Box, but -// the branch merge keeps only the class and drops the tracked element type, so E is ungroundable. -// The WHOLE `\Stringable & E` bound is then dropped — including the `\Stringable` operand, which -// could be enforced with no knowledge of E. `Banana` is a valid element (Banana <: Fruit) but is -// not `\Stringable`, yet it is silently accepted. +// Branch-merge agreement on a compound bound: both arms construct Box, so the merge keeps the +// element type and $box is determined to be Box. The whole `\Stringable & E` bound grounds to +// `\Stringable & Fruit`. `Banana` is a valid element (Banana <: Fruit) but is not `\Stringable`, so +// it is rejected on the `\Stringable` operand. // -// CURRENT behaviour: compiles (no bound violation). A v2 that evaluates the ungroundable operand as -// "unknown" instead of dropping the whole bound should still REJECT on the `\Stringable` operand. +// Expected behaviour: COMPILE ERROR (Banana does not extend/implement \Stringable). Grounding the +// whole bound preserves every operand — the checkable `\Stringable` constraint is never lost. function store(bool $cond): void { if ($cond) { diff --git a/test/fixture/compile/enclosing_param_bound_lenient_drop/source/Use.xphp b/test/fixture/compile/enclosing_param_bound_lenient_drop/source/Use.xphp index c4723c8..faa0186 100644 --- a/test/fixture/compile/enclosing_param_bound_lenient_drop/source/Use.xphp +++ b/test/fixture/compile/enclosing_param_bound_lenient_drop/source/Use.xphp @@ -4,12 +4,12 @@ declare(strict_types=1); namespace App; -// Known limitation (ADR-0018, "lenient drop"): both arms construct Box, so a reader knows -// $box is Box — but the branch merge keeps only the class (Box), dropping the tracked -// element type. With E ungroundable the bound `` is dropped for this call, so `Rock` -// (plainly not a Fruit) is silently accepted. +// Branch-merge agreement: both arms construct Box, so the merge keeps the element type +// (the arms agree on Box) and $box is determined to be Box. The bound `` +// grounds to ``, so `Rock` — plainly not a Fruit — is rejected. // -// CURRENT behaviour: compiles (no bound violation). A v2 that grounds this case should REJECT it. +// Expected behaviour: COMPILE ERROR (Rock does not extend/implement App\Fruit). A knowable element +// type is never dropped, so a determinate violation is never silently accepted. function pick(bool $cond): bool { if ($cond) { From 56069a76260d95263d824bb5a7a2250f8c255c3b Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 23 Jun 2026 23:56:05 +0000 Subject: [PATCH 078/114] feat(monomorphize): ground a receiver whose type comes from a method return MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend receiver-type determination so a method-generic bound that references an enclosing class type parameter is grounded and checked for more knowable receivers, instead of being dropped: - a local assigned from a call (`$x = $repo->getBox(); $x->m::<…>()`), - a chained call (`$repo->getBox()->m::<…>()`), - a `self`/`static`/`parent`-returning method, which carries the receiver's own generic instance through unchanged. The receiver's type is read from the called method's declared return type; the return type's own generic arguments are grounded through the receiver (so `getBox(): Box` on a `Repo` receiver yields `Box`). When the receiver's arguments are themselves abstract — a self-call inside the template body, or an unknown receiver — the argument stays a type parameter and the downstream grounding drops it: no determinate type is ever invented. Also ground a method-own sibling bound (``) against the call's own turbofish arguments, so it is determined and checked rather than dropped. A chained receiver is resolved once per call node (memoized by node), keeping deep call chains linear rather than exponential in their depth. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/GenericMethodCompiler.php | 235 ++++++++++++++++-- .../EnclosingParamBoundIntegrationTest.php | 200 +++++++++++++++ 2 files changed, 418 insertions(+), 17 deletions(-) diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 5f0de35..707e09c 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -419,6 +419,17 @@ private function rewriteCallSites( private array $currentScopeParamTypeArgs = []; /** @var array> */ private array $currentScopeLocalTypeArgs = []; + /** + * Memo for {@see resolveCallReturn}, keyed by `spl_object_id` of the call node. A call + * node sits at one fixed program point, so its declared-return-type resolution is + * deterministic; without the memo a chained receiver re-descends both the FQN and the + * args branch at every hop, which is O(2^N) in chain depth. With it, each hop resolves + * once. The value is the resolution result (or `null`); presence is tested with + * `array_key_exists` so a cached `null` is honoured. + * + * @var array}|null> + */ + private array $callReturnCache = []; /** * Variable name -> the Closure or ArrowFunction AST node that was * assigned to it (only when the closure carries @@ -726,6 +737,27 @@ public function enterNode(Node $node): null } else { unset($this->currentScopeLocalTypeArgs[$assignedName]); } + } elseif ($node->expr instanceof MethodCall + || $node->expr instanceof NullsafeMethodCall + || $node->expr instanceof StaticCall + ) { + // `$x = $repo->find()` / `$x = $this->getBox()`: track the call's declared + // return type so a later `$x->m::<…>()` is grounded. An unresolvable call + // clears any stale tracked type rather than leaving a wrong one in place. + $return = $this->resolveCallReturn($node->expr); + if ($return === null) { + unset( + $this->currentScopeLocalTypes[$assignedName], + $this->currentScopeLocalTypeArgs[$assignedName], + ); + } else { + $this->currentScopeLocalTypes[$assignedName] = $return[0]; + if ($return[1] !== []) { + $this->currentScopeLocalTypeArgs[$assignedName] = $return[1]; + } else { + unset($this->currentScopeLocalTypeArgs[$assignedName]); + } + } } // Track anonymous generic templates: `$id = fn(T $x) => $x` // or `$id = function(T $x): T { ... }`. The FuncCall-on- @@ -1105,10 +1137,11 @@ private function rewriteStaticCall(StaticCall $node): ?Node $args = $padded; if ($this->hierarchy !== null) { - // Static grounding is out of scope: a class type parameter is unbound in a - // static context, so pass no receiver args — an enclosing-param bound on a - // static method falls to the lenient drop inside groundBounds. - $checkedParams = $this->groundBounds($params, $classFqn, [], $declaringFqn); + // A class type parameter is unbound in a static context, so pass no receiver + // args — an enclosing-param bound on a static method falls to the lenient drop + // inside groundBounds. The call's own turbofish args are still threaded, so a + // method-own sibling bound (``) on a static method is grounded. + $checkedParams = $this->groundBounds($params, $args, $classFqn, [], $declaringFqn); Registry::checkBounds( $checkedParams, $args, @@ -1210,7 +1243,7 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): // parameter (``) against the receiver's concrete arguments, threaded // to the method's declaring class; an ungroundable bound drops to lenient. $receiverArgs = $this->resolveReceiverTypeArgs($node->var); - $checkedParams = $this->groundBounds($params, $classFqn, $receiverArgs, $declaringFqn); + $checkedParams = $this->groundBounds($params, $args, $classFqn, $receiverArgs, $declaringFqn); Registry::checkBounds( $checkedParams, $args, @@ -1276,6 +1309,45 @@ private function resolveMethodTemplate(string $receiverFqn, string $methodName): return null; } + /** + * Find any method declaration (generic OR not) by receiver FQN, walking the inheritance + * chain for an inherited declaration. Unlike {@see resolveMethodTemplate} this reads the + * full class AST, so a plain getter whose return type is what we want to track is found. + * + * @return array{0: ClassMethod, 1: string}|null [method, declaringFqn] + */ + private function findMethodDeclaration(string $receiverFqn, string $methodName): ?array + { + $direct = $this->findMethodOn($receiverFqn, $methodName); + if ($direct !== null) { + return [$direct, $receiverFqn]; + } + if ($this->hierarchy === null) { + return null; + } + foreach ($this->hierarchy->ancestorChain($receiverFqn) as $ancestorFqn) { + $inherited = $this->findMethodOn($ancestorFqn, $methodName); + if ($inherited !== null) { + return [$inherited, $ancestorFqn]; + } + } + return null; + } + + private function findMethodOn(string $classFqn, string $methodName): ?ClassMethod + { + $owner = $this->classByFqn[$classFqn] ?? null; + if ($owner === null) { + return null; + } + foreach ($owner->stmts as $stmt) { + if ($stmt instanceof ClassMethod && $stmt->name->toString() === $methodName) { + return $stmt; + } + } + return null; + } + /** * Handle a turbofish call whose generic method couldn't be resolved on * the receiver or any ancestor. A *turbofish* call (carries @@ -1373,6 +1445,15 @@ private function resolveReceiverFqn(Node $receiver): ?string } } } + // A chained call (`$this->getBox()->m::<…>()`): the receiver is itself a call, so its + // type is that call's declared return type. + if ($receiver instanceof MethodCall + || $receiver instanceof NullsafeMethodCall + || $receiver instanceof StaticCall + ) { + $return = $this->resolveCallReturn($receiver); + return $return === null ? null : $return[0]; + } return null; } @@ -1439,34 +1520,154 @@ private function resolveReceiverTypeArgs(Node $receiver): array } } } + // A chained call (`$this->getBox()->m::<…>()`): the receiver's args are the return + // type's grounded args. + if ($receiver instanceof MethodCall + || $receiver instanceof NullsafeMethodCall + || $receiver instanceof StaticCall + ) { + $return = $this->resolveCallReturn($receiver); + return $return === null ? [] : $return[1]; + } return []; } /** - * Ground each method type-param's bound against the receiver's concrete arguments. + * The class FQN and concrete generic type-args of a method/static call's DECLARED return + * type, or `null` when the return type isn't a determinable class. This is what lets a + * receiver whose type comes from a call — `$x = $repo->find(); $x->m::<…>()`, a chained + * `$this->getBox()->m::<…>()`, or a `self`/`static`/`parent`-returning factory — be + * grounded instead of treated as opaque. + * + * The return type's own generic args may reference the CALLED method's class parameters + * (`Repo { getBox(): Box }`); those are grounded through the call receiver's args + * (so `Box` on a `Repo` receiver becomes `Box`). When the receiver's args + * are themselves abstract (a `$this` self-call inside the template, or an unknown + * receiver) the arg stays a type parameter and the downstream grounding drops it — no + * determinate type is ever invented. + * + * @return array{0: string, 1: list}|null [returnFqn, groundedReturnArgs] + */ + private function resolveCallReturn(Node $call): ?array + { + $key = spl_object_id($call); + if (array_key_exists($key, $this->callReturnCache)) { + return $this->callReturnCache[$key]; + } + return $this->callReturnCache[$key] = $this->computeCallReturn($call); + } + + /** + * The uncached body of {@see resolveCallReturn}; always go through the memoizing wrapper. * - * Builds a substitution from the declaring class's parameters to the receiver's - * arguments (threaded up the inheritance chain), rewrites every bound leaf with it, and — - * when a leaf is still a bare enclosing type parameter afterwards (no receiver args, an - * inherited/opaque receiver, or the `$this` template body) — DROPS that param's bound for - * this call rather than checking it against the phantom type-param name. A bound that - * doesn't reference an enclosing param (a real class, or an F-bounded `Comparable` - * leaf) is unaffected and checked exactly as before. + * @return array{0: string, 1: list}|null + */ + private function computeCallReturn(Node $call): ?array + { + if ($call instanceof StaticCall) { + if (!$call->class instanceof Name || !$call->name instanceof Identifier) { + return null; + } + $receiverFqn = $this->resolveClassName($call->class); + $classArgs = $call->class->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + /** @var list $receiverArgs */ + $receiverArgs = is_array($classArgs) ? $classArgs : []; + } elseif ($call instanceof MethodCall || $call instanceof NullsafeMethodCall) { + if (!$call->name instanceof Identifier) { + return null; + } + $receiverFqn = $this->resolveReceiverFqn($call->var); + if ($receiverFqn === null) { + return null; + } + $receiverArgs = $this->resolveReceiverTypeArgs($call->var); + } else { + return null; + } + + // A non-generic getter (`getBox(): Box`) is the common return source and is NOT + // in $methodTemplates (which holds only generic methods), so resolve against the full + // class AST, walking ancestors for an inherited declaration. + $resolved = $this->findMethodDeclaration($receiverFqn, $call->name->toString()); + if ($resolved === null) { + return null; + } + [$method, $declaringFqn] = $resolved; + $returnType = $method->returnType; + if ($returnType instanceof NullableType) { + $returnType = $returnType->type; + } + if (!$returnType instanceof Name) { + return null; + } + + // `self` / `static` / `parent` return the receiver's own generic instance: its class + // and args carry through unchanged. (The parser strips the `<…>` off a pseudo-type + // and records no marker, so these names carry no ATTR_TEMPLATE_FQN.) + if (in_array(strtolower($returnType->toString()), ['self', 'static', 'parent'], true)) { + return [$receiverFqn, $receiverArgs]; + } + + // A parameterised return type (`Box<…>`) carries its head FQN on ATTR_TEMPLATE_FQN and + // its args on ATTR_GENERIC_ARGS; a plain class return type carries ATTR_RESOLVED_FQN. + $returnFqn = $returnType->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); + if (!is_string($returnFqn)) { + $plainFqn = $returnType->getAttribute(XphpSourceParser::ATTR_RESOLVED_FQN); + return is_string($plainFqn) ? [$plainFqn, []] : null; + } + $returnArgs = $returnType->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + /** @var list $returnArgs */ + $returnArgs = is_array($returnArgs) ? $returnArgs : []; + + // Ground the return args through the receiver: `getBox(): Box` on a `Repo` + // receiver yields `Box`. A concrete return parameterisation has no class-param + // leaves, so the substitution is a no-op for it. + $classSubst = $this->classSubstitutionFor($receiverFqn, $receiverArgs, $declaringFqn); + if ($classSubst !== []) { + $returnArgs = array_map( + static fn (TypeRef $arg): TypeRef => Specializer::substituteTypeRef($arg, $classSubst), + $returnArgs, + ); + } + return [$returnFqn, $returnArgs]; + } + + /** + * Ground each method type-param's bound against the receiver's concrete arguments and the + * call's own turbofish arguments. + * + * Builds a substitution from (a) the declaring class's parameters to the receiver's + * arguments (threaded up the inheritance chain) and (b) the method's own parameters to + * this call's turbofish arguments — so a bound that references a SIBLING method parameter + * (``) grounds to that argument too. Method parameters shadow class parameters + * of the same name (the inner scope wins). It then rewrites every bound leaf with the + * combined map, and — when a leaf is still a bare type parameter afterwards (no receiver + * args, an inherited/opaque receiver, or the `$this` template body) — DROPS that param's + * bound for this call rather than checking it against the phantom type-param name. A bound + * that doesn't reference an enclosing/sibling param (a real class, or an F-bounded + * `Comparable` leaf) is unaffected and checked exactly as before. * * @param list $params + * @param list $methodArgs the call's turbofish args, positionally per param * @param list $receiverArgs * @return list */ - private function groundBounds(array $params, string $receiverFqn, array $receiverArgs, string $declaringFqn): array + private function groundBounds(array $params, array $methodArgs, string $receiverFqn, array $receiverArgs, string $declaringFqn): array { - $classSubst = $this->classSubstitutionFor($receiverFqn, $receiverArgs, $declaringFqn); + $subst = $this->classSubstitutionFor($receiverFqn, $receiverArgs, $declaringFqn); + // Method-own params shadow class params of the same name, so they are layered last. + foreach ($params as $i => $param) { + if (isset($methodArgs[$i])) { + $subst[$param->name] = $methodArgs[$i]; + } + } return array_map( - static function (TypeParam $param) use ($classSubst): TypeParam { + static function (TypeParam $param) use ($subst): TypeParam { if ($param->bound === null) { return $param; } - $grounded = Registry::substituteBound($param->bound, $classSubst); + $grounded = Registry::substituteBound($param->bound, $subst); if (self::boundHasUngroundedLeaf($grounded)) { return new TypeParam($param->name, null, $param->default, $param->variance); } diff --git a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php index fa17a2c..4f9f443 100644 --- a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php +++ b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php @@ -357,8 +357,208 @@ function viaClosure(): callable { ]); } + // --- receiver type from a method return / chain / self-static --- + + public function testReturnTypedLocalReceiverGroundsAndRejects(): void + { + // `$x = $repo->getBox()` where `getBox(): Box` — the local's element type comes from + // the declared return type, grounds to Fruit, and `Food` (a supertype) is rejected. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('extend/implement "App\\Fruit"'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::box(), + 'Repo.xphp' => self::repo(), + 'Use.xphp' => <<<'PHP' + getBox(); + return $x->contains::(new Food()); + } + PHP, + ]); + } + + public function testChainedReturnReceiverGroundsAndAccepts(): void + { + // `$repo->getBox()->contains::()` — the chain head's return type `Box` grounds + // the bound to Fruit, and Banana (a Fruit) is accepted and specialized. + $dist = $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::box(), + 'Repo.xphp' => self::repo(), + 'Use.xphp' => <<<'PHP' + getBox()->contains::(new Banana()); + } + PHP, + ]); + + self::assertStringContainsString('contains_', self::read($dist, 'Use.php')); + } + + public function testSelfReturningChainCarriesReceiverArgsAndRejects(): void + { + // `copy(): static` returns the receiver's own generic instance, so `$box->copy()` is still + // Box; `contains::` then rejects Fruit (not a subtype of Banana). + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('extend/implement "App\\Banana"'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => <<<'PHP' + { + public function copy(): static { return $this; } + public function contains(U $value): bool { return true; } + } + PHP, + 'Use.xphp' => <<<'PHP' + (); + return $box->copy()->contains::(new Fruit()); + } + PHP, + ]); + } + + public function testStaticFactoryReturnReceiverGroundsAndRejects(): void + { + // A static call's declared return type grounds the assigned local: `Factory::make(): Box` + // makes `$x` a Box, and `contains::` rejects the supertype Food. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('extend/implement "App\\Fruit"'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::box(), + 'Factory.xphp' => <<<'PHP' + { return new Box::(); } + } + PHP, + 'Use.xphp' => <<<'PHP' + contains::(new Food()); + } + PHP, + ]); + } + + public function testDeepSelfReturningChainResolvesWithoutBlowup(): void + { + // Regression: resolveCallReturn must memoize. Without it, a chained receiver re-descends both + // the FQN and the args branch at every hop — O(2^N) in chain depth — and a ~24-deep `copy()` + // chain hangs. Memoized, it resolves at once, still grounding to Box and rejecting Fruit. + $chain = str_repeat('->copy()', 24); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('extend/implement "App\\Banana"'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => <<<'PHP' + { + public function copy(): static { return $this; } + public function contains(U $value): bool { return true; } + } + PHP, + 'Use.xphp' => <<(); + return \$box{$chain}->contains::(new Fruit()); + } + PHP, + ]); + } + + // --- method-own sibling bound `` grounded against the call's turbofish args --- + + public function testMethodOwnSiblingBoundAcceptsSubtypeArg(): void + { + $dist = $this->compile([ + 'Models.xphp' => self::MODELS, + 'Util.xphp' => self::pairUtil(), + 'Use.xphp' => <<<'PHP' + pair::(new Fruit(), new Banana()); + } + PHP, + ]); + + self::assertStringContainsString('pair_', self::read($dist, 'Use.php')); + } + + public function testMethodOwnSiblingBoundRejectsNonSubtypeArg(): void + { + // `pair` with `` — V (Fruit) must be a subtype of U (Banana); it is + // not, so the call is rejected. The bound grounds against the call's own turbofish args. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('extend/implement "App\\Banana"'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Util.xphp' => self::pairUtil(), + 'Use.xphp' => <<<'PHP' + pair::(new Banana(), new Fruit()); + } + PHP, + ]); + } + // --- harness --- + private static function repo(): string + { + return <<<'PHP' + { return new Box::(); } + } + PHP; + } + + private static function pairUtil(): string + { + return <<<'PHP' + (U $a, V $b): bool { return true; } + } + PHP; + } + private static function box(): string { return <<<'PHP' From f0f59a7c00493b0fdc5ebb19f99ac47ad7c3f96f Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 24 Jun 2026 00:27:01 +0000 Subject: [PATCH 079/114] feat(monomorphize): fail a method-generic bound that can't be proven MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a method-generic bound references an enclosing class type parameter (`contains`) or a sibling method parameter (``) and the receiver's type argument can't be determined at the call site, the bound cannot be proven. Replace the previous silent drop with a compile error (`xphp.bound_unprovable`): ground the bound or fail the build — never skip the check, never defer it to runtime. The diagnostic carries an actionable remedy: bind the receiver to a typed local (`Box $x = ...;`) or pass it as a typed parameter so its type arguments are known. In check mode it is collected and the pass continues; in compile mode it throws. This also fails a `$this`-rooted self-call that turbofishes an enclosing- parameter-bounded method (`$this->contains::()`). Whether such a bound holds is instance-dependent — valid for some instantiations of the enclosing class, invalid for others — and the bound checker only runs on the abstract class template, so the call was previously checked by nobody and a violating specialization could reach the emitted output. It now fails with a self-call-specific message; a future per-instantiation bound check can relax this into a real check. A static method whose bound references a class type parameter likewise fails: a static context has no instance to ground it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/GenericMethodCompiler.php | 167 +++++++++++++++--- src/Transpiler/Monomorphize/Registry.php | 5 +- .../EnclosingParamBoundIntegrationTest.php | 159 +++++++++++++++-- 3 files changed, 291 insertions(+), 40 deletions(-) diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 707e09c..62083dc 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -88,6 +88,7 @@ final class GenericMethodCompiler public const CODE_UNSUPPORTED_THIS_CAPTURE = 'xphp.closure_this_capture'; public const CODE_UNSUPPORTED_STATIC_CLOSURE = 'xphp.static_closure'; public const CODE_UNRESOLVED_GENERIC_CALL = 'xphp.unresolved_generic_call'; + public const CODE_BOUND_UNPROVABLE = 'xphp.bound_unprovable'; /** * @param ?DiagnosticCollector $diagnostics When null (the default — `xphp compile`), every @@ -1137,11 +1138,20 @@ private function rewriteStaticCall(StaticCall $node): ?Node $args = $padded; if ($this->hierarchy !== null) { - // A class type parameter is unbound in a static context, so pass no receiver - // args — an enclosing-param bound on a static method falls to the lenient drop - // inside groundBounds. The call's own turbofish args are still threaded, so a - // method-own sibling bound (``) on a static method is grounded. - $checkedParams = $this->groundBounds($params, $args, $classFqn, [], $declaringFqn); + // A class type parameter is unbound in a static context (no instance to ground + // `E`), so pass no receiver args and never defer: a class-param bound on a static + // method is genuinely unprovable and fails. The call's own turbofish args are + // still threaded, so a method-own sibling bound (``) is grounded. + $checkedParams = $this->groundBounds( + $params, + $args, + $classFqn, + [], + $declaringFqn, + false, + $classFqn . '::' . $methodName, + $location, + ); Registry::checkBounds( $checkedParams, $args, @@ -1241,9 +1251,20 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): if ($this->hierarchy !== null) { // Ground a method-generic bound that references an enclosing class type // parameter (``) against the receiver's concrete arguments, threaded - // to the method's declaring class; an ungroundable bound drops to lenient. + // to the method's declaring class. An ungroundable bound is a compile error; the + // `$this`-rooted flag only tailors the diagnostic's remedy (a self-call can't + // bind to a typed local), it does not suppress the failure. $receiverArgs = $this->resolveReceiverTypeArgs($node->var); - $checkedParams = $this->groundBounds($params, $args, $classFqn, $receiverArgs, $declaringFqn); + $checkedParams = $this->groundBounds( + $params, + $args, + $classFqn, + $receiverArgs, + $declaringFqn, + $this->receiverRootedAtThis($node->var), + $classFqn . '::' . $methodName, + $location, + ); Registry::checkBounds( $checkedParams, $args, @@ -1457,6 +1478,28 @@ private function resolveReceiverFqn(Node $receiver): ?string return null; } + /** + * Whether the receiver expression bottoms out at `$this` — directly (`$this->m`), through + * a property (`$this->prop->m`), or through a chain (`$this->getBox()->m`). Used only to + * tailor the `xphp.bound_unprovable` remedy: a `$this`-rooted self-call references the + * enclosing class's own (abstract) type parameter, so the "bind to a typed local" advice + * doesn't apply and a self-call-specific message is shown instead. It does NOT change + * whether the bound fails — an unprovable bound always fails. + */ + private function receiverRootedAtThis(Node $receiver): bool + { + if ($receiver instanceof Variable) { + return $receiver->name === 'this'; + } + if ($receiver instanceof MethodCall + || $receiver instanceof NullsafeMethodCall + || $receiver instanceof PropertyFetch + ) { + return $this->receiverRootedAtThis($receiver->var); + } + return false; + } + /** * The receiver's concrete generic type arguments, parallel to {@see resolveReceiverFqn}: * - `$this` -> the enclosing class's own type params, as identity TypeRefs (a @@ -1641,19 +1684,38 @@ private function computeCallReturn(Node $call): ?array * this call's turbofish arguments — so a bound that references a SIBLING method parameter * (``) grounds to that argument too. Method parameters shadow class parameters * of the same name (the inner scope wins). It then rewrites every bound leaf with the - * combined map, and — when a leaf is still a bare type parameter afterwards (no receiver - * args, an inherited/opaque receiver, or the `$this` template body) — DROPS that param's - * bound for this call rather than checking it against the phantom type-param name. A bound - * that doesn't reference an enclosing/sibling param (a real class, or an F-bounded - * `Comparable` leaf) is unaffected and checked exactly as before. + * combined map. + * + * When a leaf is still a bare type parameter afterwards the bound is UNPROVABLE — the + * receiver's type argument for it isn't determinable here. Maximum Runtime Safety forbids + * silently accepting it, so this is a compile error (`xphp.bound_unprovable`); it is never + * dropped. A bound that doesn't reference an enclosing/sibling param (a real class, or an + * F-bounded `Comparable` leaf) is unaffected and checked exactly as before. + * + * `$receiverIsThis` only tailors the diagnostic's remedy: a `$this`-rooted self-call can't + * "bind to a typed local" (the receiver is `$this`), and its enclosing parameter is + * abstract until the class is instantiated — a per-instantiation re-check (the future + * relaxation) would turn this hard error into a real check. It does NOT suppress the + * error: a `$this->m::()` self-call against an enclosing-param bound is checkable + * per instantiation and currently checked by nobody, so it must fail rather than slip + * through. (A self-call with no concrete turbofish never reaches grounding — the + * all-concrete guard at the call site bails first — so it is unaffected.) * * @param list $params * @param list $methodArgs the call's turbofish args, positionally per param * @param list $receiverArgs * @return list */ - private function groundBounds(array $params, array $methodArgs, string $receiverFqn, array $receiverArgs, string $declaringFqn): array - { + private function groundBounds( + array $params, + array $methodArgs, + string $receiverFqn, + array $receiverArgs, + string $declaringFqn, + bool $receiverIsThis, + string $context, + SourceLocation $location, + ): array { $subst = $this->classSubstitutionFor($receiverFqn, $receiverArgs, $declaringFqn); // Method-own params shadow class params of the same name, so they are layered last. foreach ($params as $i => $param) { @@ -1662,18 +1724,71 @@ private function groundBounds(array $params, array $methodArgs, string $receiver } } - return array_map( - static function (TypeParam $param) use ($subst): TypeParam { - if ($param->bound === null) { - return $param; - } - $grounded = Registry::substituteBound($param->bound, $subst); - if (self::boundHasUngroundedLeaf($grounded)) { - return new TypeParam($param->name, null, $param->default, $param->variance); - } - return new TypeParam($param->name, $grounded, $param->default, $param->variance); - }, - $params, + $checked = []; + foreach ($params as $param) { + if ($param->bound === null) { + $checked[] = $param; + continue; + } + $grounded = Registry::substituteBound($param->bound, $subst); + if (self::boundHasUngroundedLeaf($grounded)) { + $this->failUnprovableBound($param->name, $param->bound, $context, $receiverIsThis, $location); + // In check mode failUnprovableBound collects and returns; drop the now-checked + // bound so the pass can continue and surface any further diagnostics. + $checked[] = new TypeParam($param->name, null, $param->default, $param->variance); + continue; + } + $checked[] = new TypeParam($param->name, $grounded, $param->default, $param->variance); + } + return $checked; + } + + /** + * A method-generic bound references an enclosing/sibling type parameter whose concrete + * value isn't determinable at this call site. Collect-or-throw (matching the seam): with + * a collector (`check`) append the diagnostic and continue; without one (`compile`) throw. + */ + private function failUnprovableBound(string $paramName, BoundExpr $bound, string $context, bool $receiverIsThis, SourceLocation $location): void + { + $message = self::unprovableBoundMessage($paramName, $bound, $context, $receiverIsThis); + if ($this->diagnostics !== null) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + GenericMethodCompiler::CODE_BOUND_UNPROVABLE, + $message, + $location, + )); + return; + } + throw new RuntimeException($message); + } + + private static function unprovableBoundMessage(string $paramName, BoundExpr $bound, string $context, bool $receiverIsThis): string + { + $boundText = Registry::formatBound($bound); + if ($receiverIsThis) { + return sprintf( + 'Cannot verify generic bound `%s : %s` for %s in a `$this`-rooted self-call: the bound ' + . 'references the enclosing class\'s own type parameter, which is abstract in the class ' + . 'template, so it can only be checked once the class is instantiated. Move this call to ' + . 'a context where the receiver has a concrete element type (e.g. a function taking ' + . '`Box $b` then `$b->%s::<...>(...)`), or don\'t turbofish an enclosing-parameter-' + . 'bounded method on `$this`. (A future per-instantiation bound check will relax this.)', + $paramName, + $boundText, + $context, + // The method name is the tail of the "Class::method" context. + substr($context, (int) strrpos($context, ':') + 1), + ); + } + return sprintf( + 'Cannot verify generic bound `%s : %s` for %s: the receiver\'s type argument is not ' + . 'determinable at this call site, so the bound cannot be proven. Bind the receiver to a ' + . 'typed local (e.g. `Box $x = ...;`) or pass it as a typed parameter so its type ' + . 'arguments are known here.', + $paramName, + $boundText, + $context, ); } diff --git a/src/Transpiler/Monomorphize/Registry.php b/src/Transpiler/Monomorphize/Registry.php index 5e8fe8c..3822d2f 100644 --- a/src/Transpiler/Monomorphize/Registry.php +++ b/src/Transpiler/Monomorphize/Registry.php @@ -864,8 +864,11 @@ private static function evaluateBound(BoundExpr $bound, TypeRef $concrete, TypeH * - Intersection -> "A & B & C" * - Union -> "A | B | C" * - DNF -> "(A & B) | C" (parens around inner intersections) + * + * Public so the method-generic compiler can render the same bound form in its + * "bound unprovable" diagnostic. */ - private static function formatBound(BoundExpr $bound): string + public static function formatBound(BoundExpr $bound): string { if ($bound instanceof BoundLeaf) { return $bound->type->name; diff --git a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php index 4f9f443..e89aabe 100644 --- a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php +++ b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php @@ -8,6 +8,8 @@ use PhpParser\PrettyPrinter\Standard as StandardPrinter; use PHPUnit\Framework\TestCase; use RuntimeException; +use XPHP\Diagnostics\Diagnostic; +use XPHP\Diagnostics\DiagnosticCollector; use XPHP\FileSystem\FileFinder\NativeFileFinder; use XPHP\FileSystem\FileReader\NativeFileReader; use XPHP\FileSystem\FileWriter\NativeFileWriter; @@ -16,8 +18,9 @@ * End-to-end coverage for grounding a method-generic bound that references an enclosing class type * parameter (``) against the receiver's concrete type argument — the element-consuming * method shape on a covariant collection. Accept/reject/multi-arg pin that the bound is checked - * against the *grounded* type (not the literal `E`); the lenient cases pin that ungroundable - * receivers fall back to "no check" rather than the old misleading rejection. + * against the *grounded* type (not the literal `E`). When the receiver's type argument genuinely + * isn't determinable (and it isn't a `$this` self-call), the bound is unprovable and the build + * fails with `xphp.bound_unprovable` — ground or fail, never a silent accept. */ final class EnclosingParamBoundIntegrationTest extends TestCase { @@ -185,10 +188,14 @@ public function pick(U $value): U { return $value; } self::assertStringContainsString('pick_', self::read($dist, 'Use.php')); } - public function testThisReceiverEnclosingBoundIsLenient(): void + public function testThisSelfCallWithConcreteTurbofishIsUnprovableAndHardFails(): void { - // `$this->contains::()` inside the template body: E has no concrete value yet, so the - // bound is dropped (lenient) rather than rejected against the phantom `E`. + // `$this->contains::()` inside the template body. Whether `Banana : E` holds is + // instance-dependent (true for Box, false for Box) and the bound checker only + // runs on the abstract template, so it is checked by nobody. Ground or fail → compile error, + // with the `$this`-specific remedy (a self-call can't bind to a typed local). + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('`$this`-rooted self-call'); $this->compile([ 'Models.xphp' => self::MODELS, 'Box.xphp' => <<<'PHP' @@ -207,8 +214,6 @@ public function probe(): bool { return $this->contains::(new Banana()); $box = new Box::(); PHP, ]); - - $this->addToAssertionCount(1); } public function testParameterReceiverGroundsAndRejects(): void @@ -248,11 +253,14 @@ public function run(): bool { return $this->b->contains::(new Fruit()); } ]); } - public function testBranchMergeDropsArgsAndFallsBackToLenient(): void + public function testBranchMergeDisagreementIsUnprovableAndHardFails(): void { - // Both arms assign a Box but with DIFFERENT args; the FQN merges (still Box) yet the args - // conflict, so they are dropped → the call is lenient. If the args were not dropped, the - // call would ground to one arm's type and wrongly reject `Food` (a supertype of both). + // Both arms assign a Box but with DIFFERENT args (Fruit vs Banana); the FQN merges (still Box) + // yet the args conflict and are dropped, so the receiver's element type is undeterminable. The + // `` bound can't be proven and — not being a `$this` self-call — must fail at compile + // time rather than silently drop (Maximum Runtime Safety: ground or fail). + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot verify generic bound `U : E`'); $this->compile([ 'Models.xphp' => self::MODELS, 'Box.xphp' => self::box(), @@ -266,8 +274,6 @@ function pick(bool $c): bool { } PHP, ]); - - $this->addToAssertionCount(1); } public function testBranchMergeAgreementGroundsAndAcceptsSubtype(): void @@ -533,6 +539,103 @@ function go(): bool { ]); } + // --- hard-fail the genuine residual (ground or fail) --- + + public function testRawGenericReceiverIsUnprovableAndHardFails(): void + { + // A raw `Box` parameter (no type argument) gives the receiver a known class but no element + // type, so `` can't be proven. Not a `$this` self-call → compile error. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot verify generic bound `U : E`'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::box(), + 'Use.xphp' => <<<'PHP' + contains::(new Food()); + } + PHP, + ]); + } + + public function testStaticMethodClassParamBoundIsUnprovableAndHardFails(): void + { + // A class type parameter is unbound in a static context — there is no instance to ground `E` — + // so a static method whose bound references it is always unprovable and fails. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot verify generic bound `U : E`'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => <<<'PHP' + { + public static function pick(U $value): bool { return true; } + } + PHP, + 'Use.xphp' => <<<'PHP' + (new Banana()); + } + PHP, + ]); + } + + public function testThisSelfCallUnprovableIsCollectedInCheckMode(): void + { + // The same `$this`-rooted self-call in `check` mode: collected as `xphp.bound_unprovable` + // rather than thrown, so a whole-program check reports it instead of stopping at the first. + $collector = $this->check([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => <<<'PHP' + { + public function contains(U $value): bool { return true; } + public function probe(): bool { return $this->contains::(new Banana()); } + } + PHP, + 'Use.xphp' => <<<'PHP' + (); + PHP, + ]); + + $codes = array_map(static fn (Diagnostic $d): string => $d->code, $collector->all()); + self::assertContains(GenericMethodCompiler::CODE_BOUND_UNPROVABLE, $codes); + } + + public function testCheckModeCollectsTheUnprovableDiagnostic(): void + { + // In `check` mode the unprovable bound is collected as a diagnostic (with its actionable + // message and stable code) instead of throwing, so a whole-program check can report it. + $collector = $this->check([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::box(), + 'Use.xphp' => <<<'PHP' + contains::(new Food()); + } + PHP, + ]); + + $codes = array_map(static fn (Diagnostic $d): string => $d->code, $collector->all()); + self::assertContains(GenericMethodCompiler::CODE_BOUND_UNPROVABLE, $codes); + } + // --- harness --- private static function repo(): string @@ -603,6 +706,36 @@ private function compile(array $files): string return $dist; } + /** + * Run the sources through `check` mode, which collects diagnostics instead of throwing. + * + * @param array $files filename => contents + */ + private function check(array $files): DiagnosticCollector + { + $src = $this->work . '/' . uniqid('src', true); + mkdir($src, 0o755, true); + foreach ($files as $name => $contents) { + file_put_contents($src . '/' . $name, $contents); + } + + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + $compiler = new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + $sources = (new NativeFileFinder())->find($src) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + + return $compiler->check($sources); + } + private static function read(string $dir, string $file): string { $path = $dir . '/' . $file; From a822bcebe0fdb55df1509b84e3723c117b9001fd Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 24 Jun 2026 00:46:37 +0000 Subject: [PATCH 080/114] feat(monomorphize): fail a turbofish call on an undeterminable receiver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A generic method call is specialized at compile time and the generic method is stripped from its class, so a turbofish call whose receiver type can't be determined — an untyped `foreach` variable, or a local whose type is ambiguous after a branch — cannot be specialized. Previously such a call was silently left as `$x->m(...)`, a call to a method that no longer exists, which fatals at runtime with "undefined method". Report it at compile time instead (`xphp.undetermined_receiver`), with the fix: give the receiver a statically-known type. The compiler must never emit a call it knows will fatal at runtime — ground or fail. The branch flow-analysis guards that produce an undeterminable receiver (arms disagree, an implicit empty arm, an untracked assignment) are unchanged; only the consequence changes — a compile error in place of a silently runtime-broken emission. The tests that pinned the old de-specialized output now assert the compile error. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/GenericMethodCompiler.php | 45 ++++++- .../EnclosingParamBoundIntegrationTest.php | 46 +++++++ .../GenericMethodIntegrationTest.php | 122 +++++++----------- .../Use.expected.php | 14 -- .../Use.expected.php | 10 -- .../Use.expected.php | 12 -- .../Use.expected.php | 11 -- .../Use.expected.php | 10 -- .../Use.expected.php | 16 --- 9 files changed, 129 insertions(+), 157 deletions(-) delete mode 100644 test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingElseifMiddleArmDiffersStillDeSpecializes/Use.expected.php delete mode 100644 test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingIfWithoutElseStillDeSpecializes/Use.expected.php delete mode 100644 test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingMatchWithoutDefaultStillDeSpecializes/Use.expected.php delete mode 100644 test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingOneArmAssignsUntrackedRhsStillDeSpecializes/Use.expected.php delete mode 100644 test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingReassignmentInvalidatesPostBranchSpecialization/Use.expected.php delete mode 100644 test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingSwitchWithoutDefaultStillDeSpecializes/Use.expected.php diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 62083dc..eb0281c 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -89,6 +89,7 @@ final class GenericMethodCompiler public const CODE_UNSUPPORTED_STATIC_CLOSURE = 'xphp.static_closure'; public const CODE_UNRESOLVED_GENERIC_CALL = 'xphp.unresolved_generic_call'; public const CODE_BOUND_UNPROVABLE = 'xphp.bound_unprovable'; + public const CODE_UNDETERMINED_RECEIVER = 'xphp.undetermined_receiver'; /** * @param ?DiagnosticCollector $diagnostics When null (the default — `xphp compile`), every @@ -1201,11 +1202,12 @@ private function rewriteStaticCall(StaticCall $node): ?Node * - `$param->method::(...)` -- receiver is the function/method * parameter's declared type (snapshot in $currentScopeParamTypes). * - * Receivers we currently can't resolve (returns null -> no - * specialization, marker drops silently; user's call site becomes a - * normal MethodCall to a method that doesn't exist post-strip, surfacing - * a runtime "undefined method" error). Stage B will widen the receiver - * sources to local-variable assignments. + * When the receiver's type can't be resolved, a *turbofish* call can't be + * specialized — the generic method only exists as mangled specializations, + * so leaving it would emit a call to a method that doesn't exist and fatal + * at runtime. Ground-or-fail: report it at compile time (see + * `reportUndeterminedReceiverOrSkip`). An ordinary (non-turbofish) call on + * an unresolved receiver is none of our business and passes through. */ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): ?Node { @@ -1216,7 +1218,7 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): $classFqn = $this->resolveReceiverFqn($node->var); if ($classFqn === null) { - return null; + return $this->reportUndeterminedReceiverOrSkip($node->name->toString(), $node); } $methodName = $node->name->toString(); $key = $classFqn . '::' . $methodName; @@ -1416,6 +1418,37 @@ private static function unresolvedGenericCallMessage(string $receiverFqn, string ); } + /** + * A turbofish instance call (`$x->m::<...>()`) whose receiver type couldn't be determined. + * The call can't be specialized — the generic method only exists as mangled + * specializations — so leaving it would emit a call to a non-existent method that fatals + * at runtime. Ground-or-fail: collect-or-throw at compile time. An ordinary (non-turbofish) + * call has no generic args and passes through untouched (PHP resolves it normally). + */ + private function reportUndeterminedReceiverOrSkip(string $methodName, Node $node): null + { + if (!is_array($node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS))) { + return null; + } + $message = sprintf( + 'Cannot determine the receiver\'s type for the generic call `%s::<...>()`. A ' + . 'turbofish call is specialized at compile time, so the receiver must have a ' + . 'statically-known type. Give it a declared type — a typed parameter or property, ' + . 'or a local assigned from `new ...::<...>()` or a typed return.', + $methodName, + ); + if ($this->diagnostics !== null) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + GenericMethodCompiler::CODE_UNDETERMINED_RECEIVER, + $message, + new SourceLocation($this->currentFile, $node->getStartLine()), + )); + return null; + } + throw new RuntimeException($message); + } + /** * Resolve the static type (FQN) of a method-call receiver expression. * Returns null when the receiver type can't be determined -- the caller diff --git a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php index e89aabe..7bc048b 100644 --- a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php +++ b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php @@ -636,6 +636,52 @@ function pick(Box $b): bool { self::assertContains(GenericMethodCompiler::CODE_BOUND_UNPROVABLE, $codes); } + public function testUntypedForeachReceiverIsUndeterminedAndHardFails(): void + { + // A foreach loop variable has no declared type, so a turbofish call on it can't be + // specialized — the generic method only exists as specializations, so leaving the call would + // emit a non-existent method that fatals at runtime. Ground or fail → compile error. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot determine the receiver'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::box(), + 'Use.xphp' => <<<'PHP' + contains::(new Banana()); + } + } + PHP, + ]); + } + + public function testUndeterminedReceiverIsCollectedInCheckMode(): void + { + // The same undeterminable-receiver call in `check` mode: collected as + // `xphp.undetermined_receiver` rather than thrown. + $collector = $this->check([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::box(), + 'Use.xphp' => <<<'PHP' + contains::(new Banana()); + } + } + PHP, + ]); + + $codes = array_map(static fn (Diagnostic $d): string => $d->code, $collector->all()); + self::assertContains(GenericMethodCompiler::CODE_UNDETERMINED_RECEIVER, $codes); + } + // --- harness --- private static function repo(): string diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php index b36fba6..d703efe 100644 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php @@ -501,14 +501,15 @@ public function fooId(T $x): T { return $x; } } } - public function testBranchingReassignmentInvalidatesPostBranchSpecialization(): void + public function testBranchingReassignmentMakesReceiverUndeterminedAndFailsToCompile(): void { - // Bug fix: `$x = new Foo(); if (…) { $x = new Bar(); } $x->m::()` - // used to specialize against Bar (the last lexical write) regardless - // of whether the branch fired. The conservative fix invalidates `$x` - // on the branch's exit -- the post-branch call site no longer - // specializes, and PHP throws "undefined method" at runtime instead - // of silently calling the wrong specialization. + // `$x = new Foo(); if (…) { $x = new Bar(); } $x->m::()` must never + // specialize against the last lexical write (Bar) — the branch may not + // fire. The flow analyzer invalidates `$x` on the branch's exit, so the + // receiver's type is undetermined. A turbofish call can't be specialized + // without a known receiver type, and the generic method is stripped from + // its class, so leaving the call would emit a runtime "undefined method". + // Ground or fail: this is a compile error. $dir = sys_get_temp_dir() . '/xphp-br-post-' . uniqid('', true); mkdir($dir, 0o755, true); file_put_contents($dir . '/Foo.xphp', <<<'PHP' @@ -536,16 +537,9 @@ class Bar { public function barId(T $x): T { return $x; } } PHP); try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot determine the receiver'); $this->compileFrom($dir); - $use = file_get_contents($dir . '/dist/Use.php'); - self::assertIsString($use); - // Negative invariant kept: ambiguous post-branch type must - // de-specialize, leaving the bare unmangled call. - self::assertStringNotContainsString('fooId_T_', $use); - SnapshotHash::assertMatches( - __DIR__ . '/GenericMethodIntegrationTest/testBranchingReassignmentInvalidatesPostBranchSpecialization/Use.expected.php', - $use, - ); } finally { self::rrmdir($dir); } @@ -678,14 +672,14 @@ class Foo { public function fooId(T $x): T { return $x; } } } } - public function testBranchingIfWithoutElseStillDeSpecializes(): void + public function testBranchingIfWithoutElseUndeterminedReceiverFailsToCompile(): void { - // P5.1: if-without-else has an implicit empty arm. Even when both - // reachable paths agree on Foo (the pre-branch assignment matches - // the if-body's), the merge MUST de-specialize because the - // expectedArmCount guard trips. This is deliberate -- the implicit - // arm doesn't appear in perBranchTypes, and special-casing it - // would be fragile against refactors. + // If-without-else has an implicit empty arm. Even when both reachable + // paths agree on Foo (the pre-branch assignment matches the if-body's), + // the merge MUST NOT ground the receiver because the expectedArmCount + // guard trips (the implicit arm doesn't appear in perBranchTypes, and + // special-casing it would be fragile). The receiver's type is therefore + // undetermined, so the turbofish call can't be specialized → compile error. $dir = sys_get_temp_dir() . '/xphp-br-noelse-' . uniqid('', true); mkdir($dir, 0o755, true); file_put_contents($dir . '/Foo.xphp', <<<'PHP' @@ -707,15 +701,9 @@ class Foo { public function fooId(T $x): T { return $x; } } PHP); try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot determine the receiver'); $this->compileFrom($dir); - $use = file_get_contents($dir . '/dist/Use.php'); - self::assertIsString($use); - // Negative invariant kept: if-without-else must de-specialize. - self::assertStringNotContainsString('fooId_T_', $use); - SnapshotHash::assertMatches( - __DIR__ . '/GenericMethodIntegrationTest/testBranchingIfWithoutElseStillDeSpecializes/Use.expected.php', - $use, - ); } finally { self::rrmdir($dir); } @@ -802,9 +790,10 @@ class Foo { public function fooId(T $x): T { return $x; } } } } - public function testBranchingSwitchWithoutDefaultStillDeSpecializes(): void + public function testBranchingSwitchWithoutDefaultUndeterminedReceiverFailsToCompile(): void { - // P5.1: no `default` case = implicit fall-through = no merge. + // No `default` case = implicit fall-through = no merge, so the receiver's + // type stays undetermined and the turbofish call can't be specialized. $dir = sys_get_temp_dir() . '/xphp-br-swnod-' . uniqid('', true); mkdir($dir, 0o755, true); file_put_contents($dir . '/Foo.xphp', <<<'PHP' @@ -828,15 +817,9 @@ class Foo { public function fooId(T $x): T { return $x; } } PHP); try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot determine the receiver'); $this->compileFrom($dir); - $use = file_get_contents($dir . '/dist/Use.php'); - self::assertIsString($use); - // Negative invariant kept: switch without default must de-specialize. - self::assertStringNotContainsString('fooId_T_', $use); - SnapshotHash::assertMatches( - __DIR__ . '/GenericMethodIntegrationTest/testBranchingSwitchWithoutDefaultStillDeSpecializes/Use.expected.php', - $use, - ); } finally { self::rrmdir($dir); } @@ -886,11 +869,12 @@ class Foo { public function fooId(T $x): T { return $x; } } } } - public function testBranchingOneArmAssignsUntrackedRhsStillDeSpecializes(): void + public function testBranchingOneArmAssignsUntrackedRhsUndeterminedReceiverFailsToCompile(): void { - // P5.1: one arm assigns the same class via `new Foo()`, the other - // via an untracked RHS (a function call). The untracked arm captures - // null, the merge fails, $x de-specializes. + // One arm assigns the same class via `new Foo()`, the other via an + // untracked RHS (a function call whose return type the flow analyzer + // doesn't read here). The untracked arm captures null, the merge fails, + // and the receiver's type is undetermined → the turbofish call fails. $dir = sys_get_temp_dir() . '/xphp-br-untracked-' . uniqid('', true); mkdir($dir, 0o755, true); file_put_contents($dir . '/Foo.xphp', <<<'PHP' @@ -914,15 +898,9 @@ function computeFoo(): Foo { return new Foo(); } PHP); try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot determine the receiver'); $this->compileFrom($dir); - $use = file_get_contents($dir . '/dist/Use.php'); - self::assertIsString($use); - // Negative invariant kept: untracked-RHS arm forces de-specialization. - self::assertStringNotContainsString('fooId_T_', $use); - SnapshotHash::assertMatches( - __DIR__ . '/GenericMethodIntegrationTest/testBranchingOneArmAssignsUntrackedRhsStillDeSpecializes/Use.expected.php', - $use, - ); } finally { self::rrmdir($dir); } @@ -967,11 +945,10 @@ class Foo { public function fooId(T $x): T { return $x; } } } } - public function testBranchingMatchWithoutDefaultStillDeSpecializes(): void + public function testBranchingMatchWithoutDefaultUndeterminedReceiverFailsToCompile(): void { - // P5.1: match without default = canMergeOnLeave returns false. - // (Match would runtime-throw on unmatched value, but we're - // conservative.) + // Match without default = canMergeOnLeave returns false, so the receiver's + // type stays undetermined and the turbofish call can't be specialized. $dir = sys_get_temp_dir() . '/xphp-br-mtchnod-' . uniqid('', true); mkdir($dir, 0o755, true); file_put_contents($dir . '/Foo.xphp', <<<'PHP' @@ -995,26 +972,21 @@ class Foo { public function fooId(T $x): T { return $x; } } PHP); try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot determine the receiver'); $this->compileFrom($dir); - $use = file_get_contents($dir . '/dist/Use.php'); - self::assertIsString($use); - // Negative invariant kept: match without default must de-specialize. - self::assertStringNotContainsString('fooId_T_', $use); - SnapshotHash::assertMatches( - __DIR__ . '/GenericMethodIntegrationTest/testBranchingMatchWithoutDefaultStillDeSpecializes/Use.expected.php', - $use, - ); } finally { self::rrmdir($dir); } } - public function testBranchingElseifMiddleArmDiffersStillDeSpecializes(): void + public function testBranchingElseifMiddleArmDiffersUndeterminedReceiverFailsToCompile(): void { - // P5.1: three-arm if/elseif/else where the middle arm assigns Bar - // instead of Foo. Locks the per-arm equality loop -- if the loop - // accidentally only checks the first vs last arm, this test would - // wrongly merge against Foo. + // Three-arm if/elseif/else where the middle arm assigns Bar instead of + // Foo. Locks the per-arm equality loop -- if it accidentally only checked + // the first vs last arm it would wrongly merge against Foo and ground the + // receiver. The arms disagree, so the receiver is undetermined → the + // turbofish call can't be specialized and fails to compile. $dir = sys_get_temp_dir() . '/xphp-br-elsmid-' . uniqid('', true); mkdir($dir, 0o755, true); file_put_contents($dir . '/Foo.xphp', <<<'PHP' @@ -1041,15 +1013,9 @@ class Bar { } PHP); try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot determine the receiver'); $this->compileFrom($dir); - $use = file_get_contents($dir . '/dist/Use.php'); - self::assertIsString($use); - // Negative invariant kept: middle-arm disagreement must de-specialize. - self::assertStringNotContainsString('fooId_T_', $use); - SnapshotHash::assertMatches( - __DIR__ . '/GenericMethodIntegrationTest/testBranchingElseifMiddleArmDiffersStillDeSpecializes/Use.expected.php', - $use, - ); } finally { self::rrmdir($dir); } diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingElseifMiddleArmDiffersStillDeSpecializes/Use.expected.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingElseifMiddleArmDiffersStillDeSpecializes/Use.expected.php deleted file mode 100644 index dc4bd77..0000000 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingElseifMiddleArmDiffersStillDeSpecializes/Use.expected.php +++ /dev/null @@ -1,14 +0,0 @@ -fooId(20); diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingIfWithoutElseStillDeSpecializes/Use.expected.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingIfWithoutElseStillDeSpecializes/Use.expected.php deleted file mode 100644 index 4ee882c..0000000 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingIfWithoutElseStillDeSpecializes/Use.expected.php +++ /dev/null @@ -1,10 +0,0 @@ -fooId(12); diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingMatchWithoutDefaultStillDeSpecializes/Use.expected.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingMatchWithoutDefaultStillDeSpecializes/Use.expected.php deleted file mode 100644 index a156617..0000000 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingMatchWithoutDefaultStillDeSpecializes/Use.expected.php +++ /dev/null @@ -1,12 +0,0 @@ - $x = new Foo(), - $n === 2 => $x = new Foo(), -}; -$r = $x->fooId(19); diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingOneArmAssignsUntrackedRhsStillDeSpecializes/Use.expected.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingOneArmAssignsUntrackedRhsStillDeSpecializes/Use.expected.php deleted file mode 100644 index 3459bae..0000000 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingOneArmAssignsUntrackedRhsStillDeSpecializes/Use.expected.php +++ /dev/null @@ -1,11 +0,0 @@ -fooId(17); diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingReassignmentInvalidatesPostBranchSpecialization/Use.expected.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingReassignmentInvalidatesPostBranchSpecialization/Use.expected.php deleted file mode 100644 index 66ecd46..0000000 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingReassignmentInvalidatesPostBranchSpecialization/Use.expected.php +++ /dev/null @@ -1,10 +0,0 @@ -fooId(7); diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingSwitchWithoutDefaultStillDeSpecializes/Use.expected.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingSwitchWithoutDefaultStillDeSpecializes/Use.expected.php deleted file mode 100644 index 29be901..0000000 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest/testBranchingSwitchWithoutDefaultStillDeSpecializes/Use.expected.php +++ /dev/null @@ -1,16 +0,0 @@ -fooId(15); From c04b03eb6c3a4533885266664c9633f61c5bda5f Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 24 Jun 2026 00:46:46 +0000 Subject: [PATCH 081/114] docs: record ground-or-fail for enclosing-parameter method bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersede the earlier "lenient drop" decision for a method-generic bound that references an enclosing class type parameter. The unpublished ADR's trade-off — that an ungroundable bound is silently accepted — violated Maximum Runtime Safety and is replaced by the ground-or-fail contract: determine the receiver's type wherever it is statically knowable (a typed parameter or property, a constructed local, a method return / chained call / factory, a branch whose arms agree), and where it genuinely can't be determined, fail at compile time (`xphp.bound_unprovable`) with an actionable remedy — never skip the check, never defer it to runtime. Records the determination floor, the compound-bound and static-method behaviour, and the `$this` self-call as an intentionally loud, temporary limitation a future per-instantiation check can relax. Updates the ADR, the type-bounds guide, and the changelog to match, and documents the new `xphp.undetermined_receiver` diagnostic. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 35 +++++-- ...ric-bounds-on-enclosing-type-parameters.md | 95 ++++++++++++------- docs/syntax/type-bounds.md | 40 ++++++-- 3 files changed, 119 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d5228d..b114370 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,16 +11,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Element-typed methods on covariant collections.** A method-level type parameter bounded by an enclosing class type parameter — `class Box<+E> { public function - contains(U $value): bool }` — now has its bound **grounded** against the - receiver's concrete type argument: `Box::contains` is accepted when - `Banana <: Fruit`, and a genuine violation (`Box::contains`) is rejected - with the bound shown as the real type, not `E`. The receiver's argument is threaded up - the `extends`/`implements` chain, so a method declared on a generic interface/base and - inherited by a concrete collection is grounded too. This is the sound, element-typed - alternative to a `mixed` parameter on a covariant `<+E>` collection (`U` is invariant — - not method-level variance). Where the receiver's argument can't be determined (an opaque - receiver, or a `$this` call inside the template body) the bound is left unchecked rather - than falsely rejected. See [type bounds](docs/syntax/type-bounds.md) and + contains(U $value): bool }` — has its bound **grounded** against the receiver's + concrete type argument: `Box::contains` is accepted when `Banana <: Fruit`, + and a genuine violation (`Box::contains`) is rejected with the bound shown as + the real type, not `E`. The receiver's argument is threaded up the `extends`/`implements` + chain, so a method declared on a generic interface/base and inherited by a concrete + collection is grounded too. The receiver's type is determined from a typed parameter or + `$this->prop`, a `new`-constructed local, a value whose type comes from a method return / + chained call / `self`/`static` factory, and a branch whose arms agree on the same + parameterised type. A bound that references a sibling parameter is grounded the same way, + at the class level (`class Pair`) and the method level (``). This is the + sound, element-typed alternative to a `mixed` parameter on a covariant `<+E>` collection + (`U` is invariant — not method-level variance). **Ground or fail:** where the receiver's + type argument genuinely can't be determined, the bound can't be proven, so it is a compile + error (`xphp.bound_unprovable`) with an actionable remedy — bind the receiver to a typed + local — rather than an unchecked call. Nothing knowable is skipped, and no check is deferred + to runtime. See [type bounds](docs/syntax/type-bounds.md) and [ADR-0018](docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md). - **`xphp check`** — a validate-without-emitting CI gate. It runs every generic validation `xphp compile` does (bounds, variance, defaults, missing/duplicate @@ -72,6 +78,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 are unaffected. - **Too many type arguments are now rejected** instead of silently truncated: `Box::` for a one-parameter `Box` reports `xphp.too_many_type_arguments`. +- **A turbofish call on an undeterminable receiver is now rejected** instead of + silently emitting a runtime fatal. A generic method call like `$x->m::()` is + specialized at compile time and the generic method is stripped from its class, so + when the receiver's type can't be determined — an untyped `foreach` variable, a + local whose type is ambiguous after a branch — the call previously compiled to + `$x->m(...)`, a call to a method that no longer exists (an "undefined method" fatal + at runtime). It now fails `xphp compile` and is reported by `xphp check` as + `xphp.undetermined_receiver`, with the fix: give the receiver a statically-known + type. Ground or fail — the compiler never emits a call it knows will fatal. ## [0.2.0] diff --git a/docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md b/docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md index b3d658d..da6b3ca 100644 --- a/docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md +++ b/docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md @@ -24,7 +24,9 @@ receiver's concrete type argument before the check. - Let a covariant collection expose element-consuming methods with a *real* element-typed parameter instead of `mixed`, soundly. -- Never false-reject code that compiles today — a covariant generics library can't afford it. +- Never false-reject *provably-valid* code — determine the receiver's element type wherever it's + statically knowable (the misleading `"does not extend/implement E"` rejection must go) — but never + silently accept an *unverifiable* bound either: prove it or fail the build, never defer to runtime. - Keep the existing nominal/erased bound check; add grounding, don't replace it. - Cover the shape real libraries use: methods declared on a generic **interface/base** and inherited by concrete collections. @@ -32,17 +34,34 @@ receiver's concrete type argument before the check. ## Decision Outcome Chosen: **at a method-generic call site, ground each bound that names an enclosing class type -parameter against the receiver's concrete type arguments, then run the existing bound check.** +parameter against the receiver's concrete type arguments, then run the existing bound check — and +when the receiver's argument genuinely can't be determined, fail the build rather than skip the +check.** This is *ground or fail*, driven by the project's non-negotiable principles +[#2 Maximum Runtime Safety](../../README.md#2-maximum-runtime-safety) (never let an unverified bound +through, and never defer the check to runtime) and +[#1 Zero Runtime Penalty](../../README.md#1-zero-runtime-penalty) (the check is a compile-time fact, +the emitted code carries nothing). -- The receiver's type arguments are recovered from flow typing (a parameter's declared - `Box`, a `new Box::()` local, a `$this->prop` of declared generic type) and - threaded up the parameterized `extends`/`implements` chain to the method's **declaring** class, so - a method inherited from `Collection<+E>` grounds against an `ArrayList` receiver. -- A bound leaf that is still a bare type parameter after grounding — the argument couldn't be - determined (an opaque/inherited-but-unparameterized receiver, a post-branch merged receiver, or a - `$this` call inside the still-uninstantiated template body) — has its bound **dropped for that - call** (treated as unbounded) rather than checked against the phantom name. -- A bound that does **not** name an enclosing parameter — a real class, or an F-bounded +- **Determination floor — maximise what's knowable first.** The receiver's type arguments are + recovered from flow typing and threaded up the parameterized `extends`/`implements` chain to the + method's **declaring** class (so a method inherited from `Collection<+E>` grounds against an + `ArrayList` receiver). The covered receiver shapes: + - a parameter or `$this->prop` of declared generic type (`Box $b`); + - a `new Box::()` local (and a closure-`use` capture of one); + - a value whose type comes from a **method return**, a **chained call**, or a `self`/`static` + factory (`$x = $repo->getBox(); $x->...`, `$repo->getBox()->...`); + - a **branch** whose every arm assigns the *same* parameterised type (the arms agree → the element + type survives the merge). + A bound that references a **sibling** parameter rather than the receiver's element type is grounded + against the supplied argument the same way, both at the class level (`class Pair`) and at + the method level (``, grounded against the call's own turbofish arguments). +- **The residual is a compile error.** A bound leaf that is still a bare type parameter after + grounding — the argument genuinely couldn't be determined (a raw/unparameterised receiver, a branch + whose arms construct different types, a static call with no instance) — is reported as + `xphp.bound_unprovable` with an actionable remedy ("bind the receiver to a typed local"). It is + **never** dropped to "unbounded" and **never** checked against the phantom name. In `xphp check` + the diagnostic is collected; in `xphp compile` it aborts the build. +- A bound that does **not** name an enclosing/sibling parameter — a real class, or an F-bounded `Comparable` leaf — is untouched and checked exactly as before. ### Consequences @@ -50,20 +69,26 @@ parameter against the receiver's concrete type arguments, then run the existing - Good: the one place a covariant collection degraded to `mixed` now has a sound, element-typed parameter; `Box::contains` is accepted and `Box::contains` is rejected with the bound shown **grounded** (`Fruit`), not `E`. -- Trade-off — **lenient drop is a deliberate loosening, not "always sound".** Where the receiver's - argument can't be determined, an ungroundable bound goes from today's *hard reject* to a *silent - accept*, so a genuine violation the compiler can't analyze is no longer caught. We accept this - because the alternative (a hard error) re-introduces false-rejects on currently-compiling code, and - a blanket warning is noisy (a branch-merged receiver routinely drops its arguments). A *targeted* - `xphp check` diagnostic for the narrow "receiver known, arity correct, still ungroundable" case is - possible future work. -- Trade-off — **compound bounds drop whole.** A method bound like `` whose `E` - can't be grounded drops the entire bound, including the checkable `\Stringable` operand. Narrow (it needs a - concrete operand intersected with an ungroundable enclosing parameter) and an extension of the - lenient-drop decision; a future refinement could drop only the ungrounded operand. -- Scope — **static methods are out.** A class type parameter is unbound in a static context, so a - static method's enclosing-parameter bound is never grounded (it falls to lenient drop). There is no - use case (the motivating methods are all instance methods). +- **Ground or fail, never silently accept.** Where the receiver's argument can't be determined, an + unprovable bound is a *compile error*, not a silent accept and not a runtime check. This is the + whole point: a covariant generics library must not let an unverified element-type constraint reach + emitted PHP. The determination floor above keeps the error rare — it fires only on receivers that + carry no recoverable element type — and the message names the fix. +- **Compound bounds fail whole.** A method bound like `` whose `E` can't be + grounded fails the **entire** bound rather than checking half of it — the checkable `\Stringable` + operand isn't silently dropped, and the unprovable `E` operand isn't silently accepted. The user + grounds the receiver and the whole bound (both operands) is then checked. +- **Static methods fail.** A class type parameter is unbound in a static context — there is no + instance to ground `E` against — so a static method whose bound names a class parameter is + unprovable and fails. (The call's own method-parameter bounds, e.g. ``, still ground + against the turbofish arguments and are checked.) +- **`$this` self-calls fail, for now.** A `$this->m::()` self-call inside the class body + references the class's *own* parameter, which is abstract until the class is instantiated; whether + the bound holds is instance-dependent (valid for `Box`, not for `Box`), and the bound + checker only runs on the abstract template. Rather than silently accept it, this fails with a + self-call-specific message. This is an intentionally loud, **temporary** limitation, not a permanent + erasure boundary: a future per-instantiation bound check can re-ground and check the self-call once + the enclosing class is specialised, turning the hard error into a real check (the safe direction). - Boundary unchanged — grounding resolves the enclosing parameter to the receiver's argument; the grounded bound is then checked nominally/erased as before. F-bounded and generic-argument bound checking are unaffected. @@ -76,14 +101,18 @@ parameter against the receiver's concrete type arguments, then run the existing ### Confirmation -The grounding, the inheritance threading, and the lenient fallbacks are covered end-to-end: a direct -and an **inherited** (`ArrayList extends Base<+E>`) accept, a multi-argument -(`Pair::containsValue`) accept that grounds the right parameter, a reject whose -message shows the grounded bound, the unbounded method generic unchanged, and the lenient cases -(`$this` body, a parameter / property / closure-`use` receiver, and a branch-merge that drops -conflicting arguments). The receiver-argument threading is unit-tested for chains, diamonds -(agreeing → one grounding, conflicting → none), cycles, and arity gaps. The variance consistency is -pinned in the variance-position phase. See [type bounds](../syntax/type-bounds.md). +The grounding, the inheritance threading, the determination floor, and the hard-fail are covered +end-to-end: a direct and an **inherited** (`ArrayList extends Base<+E>`) accept, a +multi-argument (`Pair::containsValue`) accept that grounds the right parameter, a reject +whose message shows the grounded bound, the determined-receiver cases (parameter / property / +closure-`use` / method-return / chain / `self`-`static` / branch-arms-agree) accepting or rejecting on +the grounded type, and the unprovable cases (a raw generic parameter, a branch whose arms disagree, a +static class-parameter bound, and a `$this` self-call) failing with `xphp.bound_unprovable` — both +thrown in `compile` and collected in `check`. A sibling-parameter bound (`class Pair`) is +unit-tested accept/reject with the grounded sibling shown, and a method-own sibling bound (``) +is grounded against the turbofish arguments. The receiver-argument threading is unit-tested for chains, +diamonds (agreeing → one grounding, conflicting → none), cycles, and arity gaps. The variance +consistency is pinned in the variance-position phase. See [type bounds](../syntax/type-bounds.md). ## More Information diff --git a/docs/syntax/type-bounds.md b/docs/syntax/type-bounds.md index 5c2dec7..4484fd9 100644 --- a/docs/syntax/type-bounds.md +++ b/docs/syntax/type-bounds.md @@ -116,14 +116,38 @@ like any other bound. A genuine violation message naming the grounded type (`Fruit`). The receiver's argument is threaded through `extends`/`implements`, so a method declared on a generic interface/base and inherited by a concrete collection grounds -the same way. - -When the receiver's argument can't be determined statically — an opaque -receiver, or a `$this->m::<...>()` call inside the class body, where `E` -has no concrete value yet — the bound is **left unchecked** for that -call rather than reported as a spurious violation. (Bounding a *static* -method's type parameter by the class parameter is likewise unchecked: a -class type parameter has no value in a static context.) +the same way. The receiver's type is determined from a typed parameter +or `$this->prop`, a `new`-constructed local, a value returned by a +method or chained call (or a `self`/`static` factory), and a branch +whose arms agree on the same parameterised type. + +### Ground or fail + +If the receiver's type argument genuinely **can't** be determined — a +raw `Box` parameter with no type argument, a branch whose arms construct +different `Box<...>` types, or a `$this->m::<...>()` self-call inside the +class body (where `E` is the class's own parameter, abstract until the +class is instantiated) — the bound **cannot be proven**, so it is a +**compile error** (`xphp.bound_unprovable`) rather than an unchecked +call: + +```php +function pick(Box $b): bool { // raw Box — no element type + return $b->contains::(new Banana()); + // ^ cannot verify `U : E`: bind the receiver to a typed local + // (`Box $b`) so its type argument is known. +} +``` + +This upholds [Maximum Runtime Safety](../../README.md#2-maximum-runtime-safety): +a knowable type is never dropped, and an unprovable bound never becomes a +silent accept or a runtime check — you either ground it or the build +fails, with a message pointing at the fix. A *static* method whose bound +names a class parameter fails the same way: a class type parameter has no +value in a static context, so there is nothing to ground it to. The +`$this`-self-call case is an intentionally loud, temporary limitation (its +bound is provable per instantiation, just not yet checked there); move the +call to a context where the receiver has a concrete element type. ## Caveats From f11a15d01aed460bfc61f06f434108e8df9af49b Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 24 Jun 2026 05:43:37 +0000 Subject: [PATCH 082/114] docs: catalog the two ground-or-fail diagnostics with sample code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both hard-fail behaviors were under-documented. Add them to the canonical error catalog and the syntax guides, each with sample code: - `xphp.bound_unprovable` — an enclosing-parameter method bound that can't be grounded (raw receiver, disagreeing branch arms, static call, or a `$this` self-call). The `$this` self-call now has its own worked example in the type-bounds guide, not just prose. - `xphp.undetermined_receiver` — a turbofish call whose receiver has no statically-known type (untyped foreach variable, branch-ambiguous local). Also correct the now-stale docs that described an undeterminable receiver as a silent "de-specialization" / "non-specialized path" (a precision loss): it is a compile error, because the de-specialized call was a guaranteed runtime fatal. Updated across the turbofish, methods, how-it-works, caveats, and roadmap pages so the public record is consistent. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/caveats.md | 23 ++++---- docs/errors.md | 80 ++++++++++++++++++++++++++++ docs/guides/how-it-works.md | 5 +- docs/roadmap.md | 19 +++++-- docs/syntax/methods-and-functions.md | 16 +++--- docs/syntax/turbofish.md | 29 ++++++---- docs/syntax/type-bounds.md | 26 +++++++-- 7 files changed, 161 insertions(+), 37 deletions(-) diff --git a/docs/caveats.md b/docs/caveats.md index 21454c5..cec0d6f 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -277,22 +277,25 @@ $x = new Foo(); if ($cond) { $x = new Bar(); } -$x->m::($arg); // de-specializes -- not a Foo or Bar method call +$x->m::($arg); // compile error: xphp.undetermined_receiver ``` -The post-branch call drops to a non-specialized path because the -analysis can't prove a single class for `$x`. +The post-branch call **fails to compile**: the analysis can't prove a +single class for `$x`, and a turbofish call can only be specialized +against a known receiver type. -> This is a **precision** issue, not a soundness one. xphp will NOT -> pick the wrong class — it just gives up on the specialization. +> This is a **conservatism** issue, not a soundness one. xphp will NOT +> pick the wrong class, and it will NOT emit a runtime-broken call — it +> refuses at compile time and tells you to give `$x` a known type. ### Why -Receiver-type analysis is conservative: when `$x` is reassigned -inside a branch and the arms don't agree on a class, post-branch -calls fall back to a non-specialized path. Otherwise the compiler -could pick a class that doesn't match what the variable actually -holds at runtime. +Receiver-type analysis is conservative: when `$x` is reassigned inside a +branch and the arms don't agree on a class, the receiver's type is +undetermined. The generic method is stripped from its class at compile +time, so a non-specialized `$x->m(...)` would call a method that no +longer exists and fatal at runtime — so the compiler reports +`xphp.undetermined_receiver` instead of emitting it (ground or fail). The same-arms-agree shape IS supported: diff --git a/docs/errors.md b/docs/errors.md index 589e5b1..69ea5f1 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -47,6 +47,8 @@ The `json` and `github` formats tag each diagnostic with a stable code: | `xphp.closure_this_capture` | a generic closure/arrow used via turbofish captures `$this` (unsupported) | | `xphp.static_closure` | a generic `static` closure used via turbofish (unsupported) | | `xphp.unresolved_generic_call` | a turbofish method call (`$obj->m::<…>()` / `Foo::m::<…>()`) names a generic method that can't be resolved on the receiver's type — a typo or wrong receiver type, caught at compile time instead of fataling at runtime | +| `xphp.bound_unprovable` | a method-generic bound that references an enclosing class type parameter (`contains`) can't be proven because the receiver's type argument isn't determinable here — a raw `Box` with no argument, a branch whose arms disagree, a static call, or a `$this` self-call. Ground the receiver (bind it to a typed local) or the build fails | +| `xphp.undetermined_receiver` | a turbofish method call's receiver has no statically-known type (an untyped `foreach` variable, a local whose type is ambiguous after a branch), so the call can't be specialized — it would emit a call to a stripped method that fatals at runtime. Give the receiver a declared type | | `xphp.parse_error` | the file isn't valid PHP after the generic strip pass | | `phpstan.*` | a PHPStan finding in the compiled output, mapped back to the template declaration (the code is `phpstan.` + PHPStan's own identifier, e.g. `phpstan.return.type`; a finding that carries no identifier falls back to the literal `phpstan.error`) — present only when the PHPStan pass runs | | `phpstan.unavailable` | (Warning) no phpstan binary was found, so the PHPStan pass was skipped | @@ -116,6 +118,8 @@ In CI (GitHub Actions), one step gates the build and annotates the diff: | `already declared` ... `duplicate declaration` | [Caveats — duplicate generic template declaration](caveats.md#duplicate-generic-template-declaration) | | `was instantiated but never defined` | The template was used but no source file declared it — typo or missing import | | `could not be resolved to a declared generic method` | A turbofish method call names a generic method that can't be resolved on the receiver's type — check the method name or the receiver's type. | +| `Cannot verify generic bound` | [Type bounds — ground or fail](syntax/type-bounds.md#ground-or-fail) — the receiver's type argument isn't determinable; bind it to a typed local. | +| `Cannot determine the receiver's type` | [Turbofish — receiver-type analysis](syntax/turbofish.md#receiver-type-analysis-instance-methods) — give the receiver a declared type. | | `was instantiated with N type argument(s) but parameter ... has no default` | [Defaults](syntax/defaults.md) — supply all required args or add defaults | | `Nested generic specialization exceeded depth` | A generic refers to itself transitively too deeply (compiler aborts at depth 16) — usually a recursive instantiation cycle. Refactor to break the cycle. | | `Parser returned null AST` | The source file isn't valid PHP after the generic strip pass. Run `php -l .xphp` mentally on the cleaned source — most often a syntax error in the user code that's unrelated to generics. | @@ -201,6 +205,82 @@ parameter's bound. reason: ``` +### Unprovable enclosing-parameter bound + +A method-generic bound that references the enclosing class parameter +(`contains`) is checked by grounding `E` to the receiver's element +type. When that can't be determined, the bound can't be proven and the +build fails — ground or fail, never an unchecked call. See +[type bounds — ground or fail](syntax/type-bounds.md#ground-or-fail). + +```php +class Box<+E> { + public function contains(U $value): bool { /* ... */ } +} + +function pick(Box $b): bool { // raw Box — no element type to ground E + return $b->contains::(new Banana()); +} +``` + +``` +Cannot verify generic bound `U : E` for App\Box::contains: the receiver's type +argument is not determinable at this call site, so the bound cannot be proven. +Bind the receiver to a typed local (e.g. `Box $x = ...;`) or pass it as a +typed parameter so its type arguments are known here. +``` + +A `$this`-rooted self-call gets a variant of the message (the receiver is +`$this`, so "bind to a typed local" doesn't apply), and a static method whose +bound names a class parameter fails the same way (no instance to ground `E`): + +```php +class Box<+E> { + public function contains(U $value): bool { /* ... */ } + public function probe(): bool { + return $this->contains::(new Banana()); // E is abstract here + } +} +``` + +``` +Cannot verify generic bound `U : E` for App\Box::contains in a `$this`-rooted +self-call: the bound references the enclosing class's own type parameter, which +is abstract in the class template, so it can only be checked once the class is +instantiated. Move this call to a context where the receiver has a concrete +element type (e.g. a function taking `Box $b` then `$b->contains::<...>(...)`), +or don't turbofish an enclosing-parameter-bounded method on `$this`. (A future +per-instantiation bound check will relax this.) +``` + +### Undeterminable turbofish receiver + +A turbofish call is specialized at compile time, and the generic method is +stripped from its class, so the receiver must have a statically-known type. An +untyped `foreach` variable or a local whose type is ambiguous after a branch +can't be specialized — leaving the call would emit a non-existent method that +fatals at runtime, so it fails at compile time instead. + +```php +function pick(array $boxes): void { + foreach ($boxes as $box) { // $box has no static type + $box->contains::(new Banana()); + } +} + +// Ambiguous after a branch: +$x = new Foo(); +if (mt_rand(0, 1)) { $x = new Bar(); } +$r = $x->fooId::(7); // Foo|Bar — undeterminable +``` + +``` +Cannot determine the receiver's type for the generic call `contains::<...>()`. A +turbofish call is specialized at compile time, so the receiver must have a +statically-known type. Give it a declared type — a typed parameter or property, +or a local assigned from `new ...::<...>()` or a typed return. +``` + ### Defaults ``` diff --git a/docs/guides/how-it-works.md b/docs/guides/how-it-works.md index 41d824d..f0b99ca 100644 --- a/docs/guides/how-it-works.md +++ b/docs/guides/how-it-works.md @@ -219,8 +219,9 @@ walks the AST tracking each variable's class from typed parameters, typed properties, `$this`, and local `$x = new Foo()` assignments; when the receiver class is unambiguous, the call binds to the right specialization. When the analysis can't prove a single class (e.g., -after a branching reassignment whose arms disagree), the call falls -back to the non-specialized path rather than risking a wrong dispatch +after a branching reassignment whose arms disagree), the turbofish call +is a compile error (`xphp.undetermined_receiver`) rather than a silently +de-specialized call that would fatal at runtime — see the [branching narrowing caveat](../caveats.md#branching-narrowing-precision-loss). Method-scoped generics work on non-generic AND generic enclosing diff --git a/docs/roadmap.md b/docs/roadmap.md index 1bd6f35..e8e21bc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -125,11 +125,16 @@ upcoming one. - Generic free functions at namespace scope and bare top-level. - Nullsafe instance turbofish (`$obj?->m::()`). - Receiver-type analysis for `$this`, typed parameters, typed - properties, and local `$x = new Foo()` assignments. -- Conservative branching: post-branch calls de-specialize on - reassignment rather than risking a wrong dispatch. + properties, local `new` assignments, and a value returned by a + method / chained call / `self`-`static` factory. +- Conservative branching: when a post-branch receiver can't be proved + to a single type, a turbofish call is a compile error + (`xphp.undetermined_receiver`) rather than a runtime-broken dispatch. - Same-class merge: post-branch type kept when every reachable arm assigns the same class. +- Enclosing-parameter method bounds (`contains`) grounded against + the receiver, or a compile error (`xphp.bound_unprovable`) when the + element type can't be determined. ### Anonymous templates @@ -247,8 +252,12 @@ to ship. - Generic type aliases (e.g. `type Pair = ...`). - Variance edges on trait-owned templates. -- Branching narrowing precision: today conservatively de-specializes - when arms disagree; will track unions with runtime dispatch instead. +- Branching narrowing precision: today a turbofish call on a receiver + whose branch arms disagree is a compile error; could track unions with + runtime dispatch instead. +- Per-instantiation checking of a `$this`-self-call's enclosing-parameter + bound (today a compile error), to accept the calls that hold for the + instantiations actually used. ### Generic completeness diff --git a/docs/syntax/methods-and-functions.md b/docs/syntax/methods-and-functions.md index 6c7c333..730dd5a 100644 --- a/docs/syntax/methods-and-functions.md +++ b/docs/syntax/methods-and-functions.md @@ -113,14 +113,18 @@ build time instead of fataling at runtime with "Call to undefined method". ## Caveats - > ⚠️ Branching narrowing precision: if `$x` is reassigned inside a - branch and the arms disagree on the class, post-branch calls drop - to a non-specialized path rather than picking a possibly-wrong - class. See + branch and the arms disagree on the class, the receiver's type is + undetermined and a post-branch turbofish call is a compile error + (`xphp.undetermined_receiver`) rather than a silently de-specialized + call. See [caveats](../caveats.md#branching-narrowing-precision-loss). -- > ⚠️ Receiver-type tracking only follows local-scope assignments - and parameter types. A `$this->prop` that flows through a getter - doesn't propagate its concrete class to later call sites. +- > ⚠️ Receiver-type tracking follows declared parameter and property + types, `$this`, local `new` assignments, a value returned by a + **method** or chained call (`$x = $repo->get(); $x->m::()`), and a + branch whose arms agree. A value from a **free function** (`$x = make()`) + isn't tracked — give such a local a typed parameter/property hop, or + the turbofish call fails as an undetermined receiver. ## See also diff --git a/docs/syntax/turbofish.md b/docs/syntax/turbofish.md index d99cb8d..87491ce 100644 --- a/docs/syntax/turbofish.md +++ b/docs/syntax/turbofish.md @@ -78,15 +78,23 @@ $id('T_', 42); ## Receiver-type analysis (instance methods) For instance-method turbofish `$x->m::(...)`, the compiler needs to -know what class `$x` holds to pick the right method template. It uses: +know what class `$x` holds to pick the right method template. The +receiver's type is determined from: -1. The receiver's declared type: typed parameter, typed property, +1. A declared type: a typed parameter, a typed property (`$this->prop`), or `$this`. -2. Local-scope tracking on `$x = new Foo()` assignments. - -If the analysis can't prove a single class (e.g., `$x` reassigned -inside a branch where the arms disagree), the call drops to a -non-specialized path rather than picking a possibly-wrong class. +2. A local assigned from `new Foo()`, a method return, a chained call, + or a `self`/`static` factory. +3. A branch whose arms all agree on the same class. + +If the analysis can't prove a single class — `$x` is an untyped +`foreach` variable, or it's reassigned across a branch whose arms +disagree — the turbofish call **can't be specialized**. The generic +method is stripped from its class, so a non-specialized call would fatal +at runtime ("undefined method"); rather than emit that, the compiler +reports a **compile-time error** +([`xphp.undetermined_receiver`](../errors.md#diagnostic-codes)). Give the +receiver a statically-known type. Once the receiver class is known, the method is resolved through its **inheritance chain** (nearest ancestor first), so a generic method @@ -103,9 +111,10 @@ than a silent pass-through that fatals at runtime. ## Caveats - > ⚠️ **Branching narrowing precision** — receiver-type analysis - conservatively de-specializes after `$x` is reassigned across - branches whose arms disagree. See - [caveats](../caveats.md#branching-narrowing-precision-loss). + conservatively refuses to ground `$x` after it's reassigned across + branches whose arms disagree; a turbofish call there is a compile + error (`xphp.undetermined_receiver`), not a silent de-specialization. + See [caveats](../caveats.md#branching-narrowing-precision-loss). ## See also diff --git a/docs/syntax/type-bounds.md b/docs/syntax/type-bounds.md index 4484fd9..488ec39 100644 --- a/docs/syntax/type-bounds.md +++ b/docs/syntax/type-bounds.md @@ -144,10 +144,28 @@ a knowable type is never dropped, and an unprovable bound never becomes a silent accept or a runtime check — you either ground it or the build fails, with a message pointing at the fix. A *static* method whose bound names a class parameter fails the same way: a class type parameter has no -value in a static context, so there is nothing to ground it to. The -`$this`-self-call case is an intentionally loud, temporary limitation (its -bound is provable per instantiation, just not yet checked there); move the -call to a context where the receiver has a concrete element type. +value in a static context, so there is nothing to ground it to. + +The `$this`-self-call case is an intentionally loud, temporary limitation — +its bound is provable per instantiation, just not yet checked there: + +```php +class Box<+E> { + public function contains(U $value): bool { /* ... */ } + + public function probe(): bool { + // E is the class's own parameter, abstract until Box is instantiated; + // whether `Banana : E` holds is instance-dependent (Box yes, + // Box no), so this fails rather than risk an unchecked call. + return $this->contains::(new Banana()); + } +} +``` + +Move such a call to a context where the receiver has a concrete element type +(e.g. a free function taking `Box $b`). A self-call with no concrete +turbofish (`$this->contains::($v)`, forwarding a method parameter) is +unaffected. ## Caveats From 5853249bbd0b7c395de6468475eb336b3576ea02 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 24 Jun 2026 06:12:05 +0000 Subject: [PATCH 083/114] docs: correct the forwarding "workaround" for an enclosing-bound self-call Making a method generic and forwarding its own type parameter (`probe(U $v) { return $this->contains::($v); }`) is NOT a workaround for the `$this`-self-call limitation: the forwarded turbofish isn't re-specialized when the method is, so it compiles to a plain `$this->contains(...)` call to a stripped method that fatals at runtime. Replace the earlier "is unaffected" note, which spoke only to the hard-fail not firing, with the accurate guidance. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/syntax/type-bounds.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/syntax/type-bounds.md b/docs/syntax/type-bounds.md index 488ec39..50a2a88 100644 --- a/docs/syntax/type-bounds.md +++ b/docs/syntax/type-bounds.md @@ -163,9 +163,13 @@ class Box<+E> { ``` Move such a call to a context where the receiver has a concrete element type -(e.g. a free function taking `Box $b`). A self-call with no concrete -turbofish (`$this->contains::($v)`, forwarding a method parameter) is -unaffected. +(e.g. a free function taking `Box $b`). Making the method itself generic +and forwarding its own parameter — `probe(U $v) { return +$this->contains::($v); }` — does **not** work around it: the `probe` call +site is checked, but the forwarded `$this->contains::()` is not re-specialized +when `probe` is, so it compiles to a `$this->contains(...)` call that fatals at +runtime. Until the per-instantiation check lands, keep an enclosing-parameter- +bounded call out of the class body entirely. ## Caveats From b37836763b630383b18cc8c5ebf64f33be0adeb1 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 24 Jun 2026 07:10:02 +0000 Subject: [PATCH 084/114] feat(monomorphize): fail a self-call that forwards a type parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A generic method call is specialized at compile time and the generic method is stripped from its class. A `$this`-rooted self-call that forwards an abstract type parameter — `probe(U $v) { return $this->contains::($v); }` — can't be specialized at the template: the forwarded `U` is concrete only per instantiation, so the Specializer strips the turbofish and emits a bare `$this->contains(...)` call to a method that was never specialized, fataling at runtime with "undefined method". Report it at compile time instead (`xphp.unspecializable_self_call`) rather than silently emitting the broken call — ground or fail, never emit code we know will fatal. The remedy: move the call to a context where the receiver has a concrete element type. Collected in `check`, thrown in `compile`. Not bound-specific — forwarding to an unbounded generic method fails the same way. The arity check now runs before the concreteness check so a mis-sized self-call reports only the arity error, not also this one. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/GenericMethodCompiler.php | 47 +++++++- .../EnclosingParamBoundIntegrationTest.php | 108 ++++++++++++++++++ 2 files changed, 154 insertions(+), 1 deletion(-) diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index eb0281c..08e98d8 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -90,6 +90,7 @@ final class GenericMethodCompiler public const CODE_UNRESOLVED_GENERIC_CALL = 'xphp.unresolved_generic_call'; public const CODE_BOUND_UNPROVABLE = 'xphp.bound_unprovable'; public const CODE_UNDETERMINED_RECEIVER = 'xphp.undetermined_receiver'; + public const CODE_UNSPECIALIZABLE_SELF_CALL = 'xphp.unspecializable_self_call'; /** * @param ?DiagnosticCollector $diagnostics When null (the default — `xphp compile`), every @@ -1245,7 +1246,23 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): /** @var list $args — set as a list by XphpSourceParser::resolveAndAttach (or empty after the all-defaults branch above). */ $location = new SourceLocation($this->currentFile, $node->getStartLine()); $padded = Registry::padArgsWithDefaults($params, $args, $key, $this->diagnostics, $location); - if (!self::allConcrete($padded) || count($params) !== count($padded)) { + // Arity first: in `check` mode padArgsWithDefaults collects an arity diagnostic and + // returns the (still mis-sized) args, so an arity problem must short-circuit here + // before the concreteness check — otherwise a too-many/missing-arg self-call would + // also draw a spurious `unspecializable_self_call` on top of the real arity error. + if (count($params) !== count($padded)) { + return null; + } + if (!self::allConcrete($padded)) { + // A non-concrete turbofish arg is an abstract type parameter forwarded from the + // enclosing generic method (`probe{ $this->contains::(...) }`). On a + // `$this`-rooted receiver this can't be specialized at the template — the arg is + // concrete only per instantiation — and the Specializer would strip the turbofish, + // emitting a bare `$this->m(...)` to a method that was never specialized (a runtime + // fatal). Report it rather than silently dropping it. + if ($this->receiverRootedAtThis($node->var)) { + return $this->reportUnspecializableSelfCall($methodName, $location); + } return null; } $args = $padded; @@ -1449,6 +1466,34 @@ private function reportUndeterminedReceiverOrSkip(string $methodName, Node $node throw new RuntimeException($message); } + /** + * A `$this`-rooted self-call (`$this->m::()`) forwards an abstract type parameter to a + * generic method. It can't be specialized at the template — the arg is concrete only per + * instantiation — and would otherwise emit a bare call to a stripped method that fatals at + * runtime. Collect-or-throw at compile time. (A future erasure lowering will make the + * common, direct-input shape of this compile and run.) + */ + private function reportUnspecializableSelfCall(string $methodName, SourceLocation $location): null + { + $message = sprintf( + 'Cannot specialize the self-call `$this->%s::<...>()`: it forwards a type parameter ' + . 'to a generic method, which has no concrete value in the class template. Move the ' + . 'call to a context where the receiver has a concrete element type (e.g. a function ' + . 'taking a typed `Box`), or call the method on a directly-constructed value.', + $methodName, + ); + if ($this->diagnostics !== null) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + GenericMethodCompiler::CODE_UNSPECIALIZABLE_SELF_CALL, + $message, + $location, + )); + return null; + } + throw new RuntimeException($message); + } + /** * Resolve the static type (FQN) of a method-call receiver expression. * Returns null when the receiver type can't be determined -- the caller diff --git a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php index 7bc048b..10efb69 100644 --- a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php +++ b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php @@ -682,6 +682,114 @@ function pick(array $boxes): void { self::assertContains(GenericMethodCompiler::CODE_UNDETERMINED_RECEIVER, $codes); } + // --- forwarded `$this` self-call: can't be specialized at the template (stop the silent break) --- + + public function testForwardedAbstractSelfCallIsUnspecializableAndHardFails(): void + { + // `probe` forwards its own type parameter to `$this->contains::()`. The forwarded `U` + // is abstract in the template and can't be specialized there; rather than silently emit a bare + // `$this->contains(...)` to a stripped method (a runtime fatal), this is a compile error. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('forwards a type parameter'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => <<<'PHP' + { + public function contains(U $value): bool { return true; } + public function probe(U $value): bool { return $this->contains::($value); } + } + PHP, + 'Use.xphp' => <<<'PHP' + (); + PHP, + ]); + } + + public function testForwardedAbstractSelfCallIsCollectedInCheckMode(): void + { + $collector = $this->check([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => <<<'PHP' + { + public function contains(U $value): bool { return true; } + public function probe(U $value): bool { return $this->contains::($value); } + } + PHP, + 'Use.xphp' => <<<'PHP' + (); + PHP, + ]); + + $codes = array_map(static fn (Diagnostic $d): string => $d->code, $collector->all()); + self::assertContains(GenericMethodCompiler::CODE_UNSPECIALIZABLE_SELF_CALL, $codes); + } + + public function testUnboundedForwardedSelfCallAlsoHardFails(): void + { + // Not bound-specific: forwarding to an UNBOUNDED generic method breaks the same way (the inner + // turbofish is stripped and the method never specialized), so it is rejected too. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('forwards a type parameter'); + $this->compile([ + 'Models.xphp' => self::MODELS, + 'Holder.xphp' => <<<'PHP' + (T $x): T { return $x; } + public function forward(T $x): T { return $this->identity::($x); } + } + PHP, + 'Use.xphp' => <<<'PHP' + check([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => <<<'PHP' + { + public function contains(U $value): bool { return true; } + public function probe(U $value): bool { return $this->contains::($value); } + } + PHP, + 'Use.xphp' => <<<'PHP' + (); + PHP, + ]); + + $codes = array_map(static fn (Diagnostic $d): string => $d->code, $collector->all()); + self::assertContains(Registry::CODE_TOO_MANY_TYPE_ARGUMENTS, $codes); + self::assertNotContains(GenericMethodCompiler::CODE_UNSPECIALIZABLE_SELF_CALL, $codes); + } + // --- harness --- private static function repo(): string From 371e31e38831977673941e010efaf438236d1ca7 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 24 Jun 2026 07:33:47 +0000 Subject: [PATCH 085/114] feat(monomorphize): detect when a method-generic bound can be erased to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A method type parameter bounded by an enclosing class type parameter (`class Box<+E> { contains(U $value): bool }`) can be lowered by erasing `U` to its bound `E` — specialized once per class instantiation rather than once per call-site turbofish — when, and only when, `U` appears exclusively as a top-level input parameter (`U $value`). Add the analyzer for that. A nested use (`Box`), a return-position use, or a structural body use (`new U`, `instanceof U`) keeps the concrete `U` observable, so erasing would change behaviour — those stay non-erasable. A type parameter forwarded in a turbofish self-call (`$this->m::()`) lives in an attribute the AST walk never visits, so it is correctly invisible. A compound bound (``) is excluded — erasing to `E` would drop the `\Stringable` half. Conservative throughout: any uncertainty answers "not erasable", never unsound erasure. This is the analysis only; no lowering is wired to it yet. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/EnclosingBoundErasure.php | 211 ++++++++++++++++++ .../EnclosingBoundErasureTest.php | 189 ++++++++++++++++ 2 files changed, 400 insertions(+) create mode 100644 src/Transpiler/Monomorphize/EnclosingBoundErasure.php create mode 100644 test/Transpiler/Monomorphize/EnclosingBoundErasureTest.php diff --git a/src/Transpiler/Monomorphize/EnclosingBoundErasure.php b/src/Transpiler/Monomorphize/EnclosingBoundErasure.php new file mode 100644 index 0000000..9d8f6b9 --- /dev/null +++ b/src/Transpiler/Monomorphize/EnclosingBoundErasure.php @@ -0,0 +1,211 @@ + { contains(U $value): bool }`) can be lowered by **erasing `U` + * to its bound `E`** — i.e. specialized once per class instantiation (`contains_(Fruit)`) + * instead of once per call-site turbofish (`contains_(Banana)`). + * + * Erasure is sound only when every enclosing-bounded type parameter appears **exclusively** as a + * top-level input parameter (`U $value`). If it appears nested (`Box`), in the return type, or + * structurally in the body (`new U`, `instanceof U`), the concrete `U` is observable and erasing it + * to `E` would change behaviour — those keep the per-`U` lowering. A type parameter *forwarded* in a + * turbofish self-call (`$this->m::()`) is fine: it lives in `ATTR_METHOD_GENERIC_ARGS`, an + * attribute the AST `Name` walk never visits, so it's correctly invisible here. + * + * Conservative by construction: any uncertainty answers "not erasable", which falls back to the + * existing per-`U` path (and the compile error for an unspecializable forward) — never to unsound + * erasure. + */ +final class EnclosingBoundErasure +{ + /** + * @param list $methodParams the method's own generic parameters (with their bounds) + * @param list $classParamNames the enclosing class's type-parameter names + */ + public static function isErasable(ClassMethod $method, array $methodParams, array $classParamNames): bool + { + $bounded = self::enclosingBoundedNames($methodParams, $classParamNames); + if ($bounded === []) { + return false; // not an enclosing-parameter-bounded method + } + + // Each parameter is either a bare bounded name (`U $value` — the only allowed occurrence) or + // must not reference a bounded name at all (a nested `Box` makes the concrete U observable). + $seenAsInput = []; + foreach ($method->params as $param) { + $type = $param->type; + if ($type instanceof Name + && count($type->getParts()) === 1 + && in_array($type->toString(), $bounded, true) + ) { + $seenAsInput[] = $type->toString(); + continue; + } + if (self::typeReferencesBounded($type, $bounded)) { + return false; + } + } + + // Every bounded parameter must be used as a direct input at least once (else it is only + // structural / unused — not the erasable shape). + foreach ($bounded as $name) { + if (!in_array($name, $seenAsInput, true)) { + return false; + } + } + + // The return type must not mention a bounded name, and the body must not use one structurally + // (a forwarded turbofish lives in an attribute and is invisible to this Name search). + if (self::typeReferencesBounded($method->returnType, $bounded)) { + return false; + } + return !self::bodyUsesBounded($method, $bounded); + } + + /** + * Whether a type node (a parameter/return type: a Name, a nullable/union/intersection of them) + * mentions a bounded name — bare (`U`) or nested in another type's args (`Box`). + * + * @param list $bounded + */ + private static function typeReferencesBounded(?Node $type, array $bounded): bool + { + if ($type instanceof NullableType) { + return self::typeReferencesBounded($type->type, $bounded); + } + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return self::anyTypeReferencesBounded($type->types, $bounded); + } + if ($type instanceof Name) { + if (count($type->getParts()) === 1 && in_array($type->toString(), $bounded, true)) { + return true; + } + return self::genericArgsReferenceBounded($type, $bounded); + } + return false; // scalar Identifier, or no type + } + + /** + * @param array $members + * @param list $bounded + */ + private static function anyTypeReferencesBounded(array $members, array $bounded): bool + { + foreach ($members as $member) { + if (self::typeReferencesBounded($member, $bounded)) { + return true; + } + } + return false; + } + + /** + * Whether any AST `Name` in the method body mentions a bounded name structurally — `new U`, + * `instanceof U`, `U::CONST`, or a nested `new Box::()`. A turbofish forward (`$this->m::()`) + * keeps its args in `ATTR_METHOD_GENERIC_ARGS`, which is not an AST child, so it is not found here. + * + * @param list $bounded + */ + private static function bodyUsesBounded(ClassMethod $method, array $bounded): bool + { + if ($method->stmts === null) { + return false; + } + /** @var list $names */ + $names = (new NodeFinder())->findInstanceOf($method->stmts, Name::class); + foreach ($names as $name) { + if (count($name->getParts()) === 1 && in_array($name->toString(), $bounded, true)) { + return true; + } + if (self::genericArgsReferenceBounded($name, $bounded)) { + return true; + } + } + return false; + } + + /** + * Whether a Name's `ATTR_GENERIC_ARGS` (the `<...>` of a generic type reference) contains a + * bounded type-parameter reference (`Box` / `Map`). + * + * @param list $bounded + */ + private static function genericArgsReferenceBounded(Name $name, array $bounded): bool + { + $args = $name->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + if (!is_array($args)) { + return false; + } + /** @var list $args */ + foreach ($args as $arg) { + if (self::refTreeHasBounded($arg, $bounded)) { + return true; + } + } + return false; + } + + /** + * Whether a TypeRef tree contains a type-parameter reference to one of $bounded (the `U` in + * `Box` / `Map`). + * + * @param list $bounded + */ + public static function refTreeHasBounded(TypeRef $ref, array $bounded): bool + { + if ($ref->isTypeParam && in_array($ref->name, $bounded, true)) { + return true; + } + foreach ($ref->args as $arg) { + if (self::refTreeHasBounded($arg, $bounded)) { + return true; + } + } + return false; + } + + /** + * The names of the method's type parameters whose bound references an enclosing class parameter. + * + * @param list $methodParams + * @param list $classParamNames + * @return list + */ + private static function enclosingBoundedNames(array $methodParams, array $classParamNames): array + { + $out = []; + foreach ($methodParams as $param) { + if ($param->bound !== null && self::boundIsEnclosingLeaf($param->bound, $classParamNames)) { + $out[] = $param->name; + } + } + return $out; + } + + /** + * Whether the bound is *exactly* a single enclosing class type parameter (``). A compound + * bound (``) is deliberately excluded: erasing `U` to `E` would drop the + * `\Stringable` half, so it must keep the per-`U` lowering (where the whole bound is checked). + * + * @param list $classParamNames + */ + private static function boundIsEnclosingLeaf(BoundExpr $bound, array $classParamNames): bool + { + return $bound instanceof BoundLeaf + && $bound->type->isTypeParam + && in_array($bound->type->name, $classParamNames, true); + } +} diff --git a/test/Transpiler/Monomorphize/EnclosingBoundErasureTest.php b/test/Transpiler/Monomorphize/EnclosingBoundErasureTest.php new file mode 100644 index 0000000..1bf68aa --- /dev/null +++ b/test/Transpiler/Monomorphize/EnclosingBoundErasureTest.php @@ -0,0 +1,189 @@ + { + public function contains(U $value): bool { return true; } + public function probe(U $value): bool { return $this->contains::($value); } + public function pair(U $a, V $b): bool { return true; } + public function addAll(Box $items): void {} + public function echoBack(U $x): U { return $x; } + public function inspect(U $x): bool { return $x instanceof U; } + public function mixedUse(U $a, Box $b): bool { return true; } + public function plain(T $x): T { return $x; } + public function viaStringable(U $x): bool { return true; } + public function compound(U $x): bool { return true; } + public function noInput(): bool { return true; } + public function nullableInput(?U $x): bool { return true; } + public function unionInput(U|Fruit $x): bool { return true; } + public function construct(U $x): bool { return (new Box::()) instanceof Box; } + public function nullableReturn(U $x): ?U { return $x; } + public function unionSecond(U $a, U|Fruit $b): bool { return true; } + public function returnsConcrete(U $x): Fruit { return new Fruit(); } + public function pairMixed(U $a, Box $b): bool { return true; } + public function unionConcrete(U $a, Fruit|Banana $b): bool { return true; } + } + PHP; + + private function erasable(string $methodName): bool + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + $ast = $parser->parse(self::SOURCE); + + $finder = new NodeFinder(); + $class = $finder->findFirst($ast, static fn (Node $n): bool => $n instanceof Class_); + self::assertInstanceOf(Class_::class, $class); + $method = $finder->findFirst([$class], static fn (Node $n): bool => $n instanceof ClassMethod && $n->name->toString() === $methodName); + self::assertInstanceOf(ClassMethod::class, $method); + + /** @var list $classParams */ + $classParams = $class->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS) ?? []; + /** @var list $methodParams */ + $methodParams = $method->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS) ?? []; + $classParamNames = array_map(static fn (TypeParam $p): string => $p->name, $classParams); + + return EnclosingBoundErasure::isErasable($method, $methodParams, $classParamNames); + } + + public function testBareInputParamIsErasable(): void + { + self::assertTrue($this->erasable('contains')); + } + + public function testForwardedTurbofishStaysErasable(): void + { + // `$this->contains::()` forwards U in an attribute the AST Name walk never sees. + self::assertTrue($this->erasable('probe')); + } + + public function testMultipleBareInputParamsAreErasable(): void + { + self::assertTrue($this->erasable('pair')); + } + + public function testNestedParamIsNotErasable(): void + { + // `Box $items` — U is observable nested in the arg, so erasing it would change behaviour. + self::assertFalse($this->erasable('addAll')); + } + + public function testReturnPositionIsNotErasable(): void + { + self::assertFalse($this->erasable('echoBack')); + } + + public function testStructuralBodyUseIsNotErasable(): void + { + // `$x instanceof U` observes the concrete U. + self::assertFalse($this->erasable('inspect')); + } + + public function testMixedTopLevelAndNestedIsNotErasable(): void + { + self::assertFalse($this->erasable('mixedUse')); + } + + public function testUnboundedMethodGenericIsNotErasable(): void + { + // No enclosing-parameter bound → not an `` method → stays per-U. + self::assertFalse($this->erasable('plain')); + } + + public function testBoundOnARealInterfaceIsNotErasable(): void + { + // `` — the bound is a real interface, not an enclosing class parameter. + self::assertFalse($this->erasable('viaStringable')); + } + + public function testCompoundBoundIsNotErasable(): void + { + // `` — erasing U to E would drop the \Stringable half; keep per-U. + self::assertFalse($this->erasable('compound')); + } + + public function testBoundedParamUnusedAsInputIsNotErasable(): void + { + // U is declared and enclosing-bounded but never a direct input — not the erasable shape. + self::assertFalse($this->erasable('noInput')); + } + + public function testNullableInputIsNotErasable(): void + { + // `?U $x` is not a bare top-level slot — conservatively not erasable. + self::assertFalse($this->erasable('nullableInput')); + } + + public function testUnionInputIsNotErasable(): void + { + // `U|Fruit $x` mentions U inside a union — not a bare top-level slot. + self::assertFalse($this->erasable('unionInput')); + } + + public function testNestedConstructionInBodyIsNotErasable(): void + { + // `new Box::()` in the body makes the concrete U observable. + self::assertFalse($this->erasable('construct')); + } + + public function testBoundedInNullableReturnIsNotErasable(): void + { + // U is a bare input but also appears in the nullable return `?U` — observable, so not erasable. + self::assertFalse($this->erasable('nullableReturn')); + } + + public function testBoundedInAUnionParamIsNotErasable(): void + { + // First param is a bare input, but a second param `U|Fruit` mentions U in a union. + self::assertFalse($this->erasable('unionSecond')); + } + + public function testBareInputWithConcreteReturnIsErasable(): void + { + // U only as a bare input; a concrete (non-U) return type doesn't block erasure. + self::assertTrue($this->erasable('returnsConcrete')); + } + + public function testSecondBoundedParamNestedIsNotErasable(): void + { + // U is a bare input but a second bounded param V appears nested in `Box`. + self::assertFalse($this->erasable('pairMixed')); + } + + public function testConcreteUnionSecondParamStaysErasable(): void + { + // A second param `Fruit|Banana` (no bounded member) doesn't block erasure of a bare-input U. + self::assertTrue($this->erasable('unionConcrete')); + } + + public function testRefTreeHasBoundedDirectly(): void + { + self::assertTrue(EnclosingBoundErasure::refTreeHasBounded(new TypeRef('U', isTypeParam: true), ['U'])); + self::assertTrue(EnclosingBoundErasure::refTreeHasBounded( + new TypeRef('Box', [new TypeRef('U', isTypeParam: true)]), + ['U'], + )); + // A real class named like a bound, but not a type parameter, doesn't match. + self::assertFalse(EnclosingBoundErasure::refTreeHasBounded(new TypeRef('U'), ['U'])); + // A type parameter that isn't in the bounded set doesn't match. + self::assertFalse(EnclosingBoundErasure::refTreeHasBounded(new TypeRef('K', isTypeParam: true), ['U'])); + } +} From 4d41bc65a0b58cdaa2b63a985b7e7ac633b589ed Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 24 Jun 2026 12:01:46 +0000 Subject: [PATCH 086/114] feat(monomorphize): lower a direct-input enclosing-bounded method by erasing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A method type parameter bounded by an enclosing class type parameter (`class Box<+E> { contains(U $value): bool }`), when `U` is used only as a direct input, is now lowered by erasing `U` to its bound `E`: one concrete `contains_(Fruit)` member per class instantiation, instead of one `contains_(Banana)` per call-site turbofish. `` is, after all, just the variance-legal spelling of "an `E`-typed input". This makes a forwarded self-call work by construction. `probe(U $v) { return $this->contains::($v); }` previously compiled to a bare `$this->contains(...)` to a method that was never specialized — a runtime fatal. Now the Specializer lowers both methods to E-mangled members on each specialization and rewrites the forward to the emitted name, so it resolves and runs. The interim hard-fail is narrowed to the non-erasable residual (a nested / return-position / structural `U`, which keeps the per-`U` path). Mechanics: the call site checks the bound, then rewrites an erasable call to the E-mangled name keyed on the receiver's element type (no per-call append); the Specializer keeps erasable methods on their generic class and erases them per instantiation. Both sides build the name through one shared helper, so they agree byte for byte — verified by executing the compiled output for the forwarding, inherited, and covariant-chain shapes (the covariant `extends` chain hosts the distinct E-mangled members with no LSP fault). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/Compiler.php | 1 + .../Monomorphize/EnclosingBoundErasure.php | 28 +++- .../Monomorphize/GenericMethodCompiler.php | 74 +++++++++- src/Transpiler/Monomorphize/Registry.php | 12 ++ src/Transpiler/Monomorphize/Specializer.php | 129 +++++++++++++++++- .../EnclosingBoundErasureTest.php | 30 ++++ .../EnclosingParamBoundIntegrationTest.php | 119 +++++++++++++--- .../source/Banana.xphp | 9 ++ .../source/Box.xphp | 13 ++ .../source/Food.xphp | 9 ++ .../source/Fruit.xphp | 9 ++ .../source/Use.xphp | 21 +++ .../verify/runtime.php | 23 ++++ .../source/Banana.xphp | 9 ++ .../source/Box.xphp | 19 +++ .../source/Fruit.xphp | 9 ++ .../source/Use.xphp | 9 ++ .../verify/runtime.php | 22 +++ .../source/ArrayList.xphp | 9 ++ .../source/Banana.xphp | 9 ++ .../source/Base.xphp | 13 ++ .../source/Fruit.xphp | 9 ++ .../source/Use.xphp | 10 ++ .../verify/runtime.php | 21 +++ 24 files changed, 586 insertions(+), 30 deletions(-) create mode 100644 test/fixture/compile/enclosing_bound_erasure_covariant_chain/source/Banana.xphp create mode 100644 test/fixture/compile/enclosing_bound_erasure_covariant_chain/source/Box.xphp create mode 100644 test/fixture/compile/enclosing_bound_erasure_covariant_chain/source/Food.xphp create mode 100644 test/fixture/compile/enclosing_bound_erasure_covariant_chain/source/Fruit.xphp create mode 100644 test/fixture/compile/enclosing_bound_erasure_covariant_chain/source/Use.xphp create mode 100644 test/fixture/compile/enclosing_bound_erasure_covariant_chain/verify/runtime.php create mode 100644 test/fixture/compile/enclosing_bound_erasure_forwarding/source/Banana.xphp create mode 100644 test/fixture/compile/enclosing_bound_erasure_forwarding/source/Box.xphp create mode 100644 test/fixture/compile/enclosing_bound_erasure_forwarding/source/Fruit.xphp create mode 100644 test/fixture/compile/enclosing_bound_erasure_forwarding/source/Use.xphp create mode 100644 test/fixture/compile/enclosing_bound_erasure_forwarding/verify/runtime.php create mode 100644 test/fixture/compile/enclosing_bound_erasure_inherited/source/ArrayList.xphp create mode 100644 test/fixture/compile/enclosing_bound_erasure_inherited/source/Banana.xphp create mode 100644 test/fixture/compile/enclosing_bound_erasure_inherited/source/Base.xphp create mode 100644 test/fixture/compile/enclosing_bound_erasure_inherited/source/Fruit.xphp create mode 100644 test/fixture/compile/enclosing_bound_erasure_inherited/source/Use.xphp create mode 100644 test/fixture/compile/enclosing_bound_erasure_inherited/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index b2161c3..b8f33e0 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -148,6 +148,7 @@ public function compile( $specialized = $this->specializer->specialize( $definition->templateAst, $substitution, + $this->hashLength, ); $specializedAsts[$generatedFqn] = $specialized; diff --git a/src/Transpiler/Monomorphize/EnclosingBoundErasure.php b/src/Transpiler/Monomorphize/EnclosingBoundErasure.php index 9d8f6b9..5a405e6 100644 --- a/src/Transpiler/Monomorphize/EnclosingBoundErasure.php +++ b/src/Transpiler/Monomorphize/EnclosingBoundErasure.php @@ -37,9 +37,12 @@ final class EnclosingBoundErasure */ public static function isErasable(ClassMethod $method, array $methodParams, array $classParamNames): bool { + // Every type parameter must be enclosing-bounded (``). This rejects both a method with + // no enclosing-bounded parameter at all (``, `` → empty `$bounded`) and a + // mixed method (``) whose `W` needs real per-`W` specialization. $bounded = self::enclosingBoundedNames($methodParams, $classParamNames); - if ($bounded === []) { - return false; // not an enclosing-parameter-bounded method + if (count($bounded) !== count($methodParams)) { + return false; } // Each parameter is either a bare bounded name (`U $value` — the only allowed occurrence) or @@ -158,6 +161,27 @@ private static function genericArgsReferenceBounded(Name $name, array $bounded): return false; } + /** + * The TypeRef list an erasable method is mangled on: each enclosing-bounded parameter contributes + * the **concrete** value of its bound's class parameter (`U : E` on `Box` → `Fruit`). The + * call site and the class specialization both compute this from the same `$classConcrete` map, so + * they produce the byte-identical mangled name (`contains_T_`) — the cross-cutting invariant. + * + * @param list $methodParams + * @param array $classConcrete class-parameter name → its concrete TypeRef + * @return list + */ + public static function mangleArgs(array $methodParams, array $classConcrete): array + { + $out = []; + foreach ($methodParams as $param) { + if ($param->bound instanceof BoundLeaf && isset($classConcrete[$param->bound->type->name])) { + $out[] = $classConcrete[$param->bound->type->name]; + } + } + return $out; + } + /** * Whether a TypeRef tree contains a type-parameter reference to one of $bounded (the `U` in * `Box` / `Map`). diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 08e98d8..62bbd94 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -231,9 +231,23 @@ public function process(array &$astSet, bool $emit = true): void // more than one `::` so the third capture would be empty in either case. [$classFqn, $methodName] = explode('::', $key, 2); $class = $classByFqn[$classFqn] ?? null; - if ($class !== null) { - $this->stripMethod($class, $methodName); + if ($class === null) { + continue; + } + // An erasable `` method is KEPT on its (generic) class so the Specializer can erase + // it into a concrete `contains_T_(E)` member per instantiation. The generic class is + // lowered to a marker interface in the user file, so the kept template never reaches output. + $methodParams = $template->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + $classParams = $class->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + if (is_array($methodParams) && is_array($classParams)) { + /** @var list $methodParams */ + /** @var list $classParams */ + $classParamNames = array_map(static fn (TypeParam $p): string => $p->name, $classParams); + if (EnclosingBoundErasure::isErasable($template, $methodParams, $classParamNames)) { + continue; + } } + $this->stripMethod($class, $methodName); } // Strip the original function templates: namespaced ones get stripped from @@ -1257,10 +1271,13 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): // A non-concrete turbofish arg is an abstract type parameter forwarded from the // enclosing generic method (`probe{ $this->contains::(...) }`). On a // `$this`-rooted receiver this can't be specialized at the template — the arg is - // concrete only per instantiation — and the Specializer would strip the turbofish, - // emitting a bare `$this->m(...)` to a method that was never specialized (a runtime - // fatal). Report it rather than silently dropping it. - if ($this->receiverRootedAtThis($node->var)) { + // concrete only per instantiation. When the target is ERASABLE the Specializer + // rewrites this self-call to the target's E-mangled name per instantiation, so it + // resolves; leave it for that pass. Otherwise it would emit a bare `$this->m(...)` + // to a method that was never specialized (a runtime fatal) — so report it. + if ($this->receiverRootedAtThis($node->var) + && !$this->isErasableTarget($template, $params, $declaringFqn) + ) { return $this->reportUnspecializableSelfCall($methodName, $location); } return null; @@ -1292,6 +1309,31 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): $this->diagnostics, $location, ); + + // Erasable `` method: the bound is checked above, but the call lowers to + // the E-mangled name keyed on the RECEIVER's element type (not the turbofish arg), + // and the member is emitted by the Specializer per class instantiation — so there + // is no per-call append here. Both sides key on EnclosingBoundErasure::mangleArgs, + // producing the same name. + $declaringClass = $this->classByFqn[$declaringFqn] ?? null; + $classParams = $declaringClass?->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + if (is_array($classParams)) { + /** @var list $classParams */ + $classParamNames = array_map(static fn (TypeParam $p): string => $p->name, $classParams); + if (EnclosingBoundErasure::isErasable($template, $params, $classParamNames)) { + $classConcrete = $this->classSubstitutionFor($classFqn, $receiverArgs, $declaringFqn); + if ($classConcrete !== []) { + $erased = self::mangleName( + $methodName, + EnclosingBoundErasure::mangleArgs($params, $classConcrete), + $this->hashLength, + ); + $node->name = new Identifier($erased, $node->name->getAttributes()); + $node->setAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS, null); + return $node; + } + } + } } $mangled = self::mangleName($methodName, $args, $this->hashLength); @@ -1494,6 +1536,24 @@ private function reportUnspecializableSelfCall(string $methodName, SourceLocatio throw new RuntimeException($message); } + /** + * Whether the called method is an erasable `` method on its declaring class — i.e. + * the Specializer will lower it (and rewrite a forwarded self-call to it) per instantiation. + * + * @param list $params + */ + private function isErasableTarget(ClassMethod $template, array $params, string $declaringFqn): bool + { + $class = $this->classByFqn[$declaringFqn] ?? null; + $classParams = $class?->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + if (!is_array($classParams)) { + return false; + } + /** @var list $classParams */ + $names = array_map(static fn (TypeParam $p): string => $p->name, $classParams); + return EnclosingBoundErasure::isErasable($template, $params, $names); + } + /** * Resolve the static type (FQN) of a method-call receiver expression. * Returns null when the receiver type can't be determined -- the caller @@ -2236,7 +2296,7 @@ private static function hasAllDefaults(array $params): bool */ private static function mangleName(string $shortName, array $args, int $hashLength): string { - return $shortName . '_T_' . Registry::canonicalHash($args, $hashLength); + return Registry::mangledMethodName($shortName, $args, $hashLength); } /** diff --git a/src/Transpiler/Monomorphize/Registry.php b/src/Transpiler/Monomorphize/Registry.php index 3822d2f..c402a89 100644 --- a/src/Transpiler/Monomorphize/Registry.php +++ b/src/Transpiler/Monomorphize/Registry.php @@ -1048,6 +1048,18 @@ public static function canonicalHash(array $args, int $hashLength = self::DEFAUL return substr(hash('sha256', $canonical), 0, $hashLength); } + /** + * The single source of truth for a mangled generic-method name: `m_T_`. The + * call site and the Specializer both build erased-method names through here so they agree byte for + * byte (the cross-cutting invariant that a rewritten call resolves to the emitted member). + * + * @param list $args + */ + public static function mangledMethodName(string $shortName, array $args, int $hashLength = self::DEFAULT_HASH_HEX_LENGTH): string + { + return $shortName . '_T_' . self::canonicalHash($args, $hashLength); + } + /** * Read XPHP_HASH_LENGTH from the environment, falling back to the default. * Throws on garbage values (non-numeric, out of range) so misconfiguration fails loud at boot. diff --git a/src/Transpiler/Monomorphize/Specializer.php b/src/Transpiler/Monomorphize/Specializer.php index 2332a10..d6a5e6e 100644 --- a/src/Transpiler/Monomorphize/Specializer.php +++ b/src/Transpiler/Monomorphize/Specializer.php @@ -5,6 +5,9 @@ namespace XPHP\Transpiler\Monomorphize; use PhpParser\Node; +use PhpParser\Node\Expr\MethodCall; +use PhpParser\Node\Expr\NullsafeMethodCall; +use PhpParser\Node\Expr\Variable; use PhpParser\Node\Identifier; use PhpParser\Node\Name; use PhpParser\Node\Name\FullyQualified; @@ -58,7 +61,7 @@ final class Specializer * The cloned class's `name` is intentionally NOT set here — SpecializedClassGenerator::emit * is the single source of truth for the final shortname (derived from the generated FQCN). */ - public function specialize(ClassLike $template, array $substitution): ClassLike + public function specialize(ClassLike $template, array $substitution, int $hashLength = Registry::DEFAULT_HASH_HEX_LENGTH): ClassLike { $originalTemplateFqn = $template->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); @@ -80,11 +83,135 @@ public function specialize(ClassLike $template, array $substitution): ClassLike } } + // Lower an erasable `` method (kept on the template by the method compiler) into a + // concrete, E-mangled member: `contains(U)` on Box becomes `contains_T_` + // taking `Fruit`. Done before the class-wide substitution; the lowered members are already + // concrete, so the substitution leaves them untouched. + $cloned->stmts = $this->lowerErasableMethods($cloned->stmts, $substitution, $hashLength); + self::runSubstitutingVisitor($cloned, $substitution); return $cloned; } + /** + * Replace each erasable generic method with its E-erased concrete form; non-erasable methods and + * non-method statements pass through unchanged. + * + * @param array<\PhpParser\Node\Stmt> $stmts + * @param array $classConcrete class-parameter name → concrete TypeRef + * @return list<\PhpParser\Node\Stmt> + */ + private function lowerErasableMethods(array $stmts, array $classConcrete, int $hashLength): array + { + $classParamNames = array_keys($classConcrete); + + // First pass: every erasable method's E-mangled name. Used to rewrite `$this->m::<...>()` + // self-calls between erasable methods to the same names the call sites produce. + $erasedNames = []; + foreach ($stmts as $stmt) { + if ($stmt instanceof ClassMethod) { + $methodParams = $stmt->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + if (is_array($methodParams)) { + /** @var list $methodParams */ + if (EnclosingBoundErasure::isErasable($stmt, $methodParams, $classParamNames)) { + $erasedNames[$stmt->name->toString()] = Registry::mangledMethodName( + $stmt->name->toString(), + EnclosingBoundErasure::mangleArgs($methodParams, $classConcrete), + $hashLength, + ); + } + } + } + } + + // Second pass: erase each erasable method into a concrete member. + $out = []; + foreach ($stmts as $stmt) { + if ($stmt instanceof ClassMethod && isset($erasedNames[$stmt->name->toString()])) { + /** @var list $methodParams */ + $methodParams = $stmt->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + $out[] = $this->eraseMethod($stmt, $methodParams, $classConcrete, $erasedNames[$stmt->name->toString()], $erasedNames); + continue; + } + $out[] = $stmt; + } + return $out; + } + + /** + * Erase one method: substitute each enclosing-bounded type parameter (and any class parameter) to + * its concrete bound, rename to the E-mangled name, drop method-genericness, and rewrite any + * `$this->m::<...>()` self-call to a fellow erasable method to that method's E-mangled name. + * + * @param list $methodParams + * @param array $classConcrete + * @param array $erasedNames erasable method name → its E-mangled name + */ + private function eraseMethod(ClassMethod $method, array $methodParams, array $classConcrete, string $mangled, array $erasedNames): ClassMethod + { + $subst = $classConcrete; + foreach ($methodParams as $param) { + // @infection-ignore-all — invariantly true: isErasable guarantees every method parameter + // is a single-leaf enclosing-class bound, so `$param->bound` IS a BoundLeaf and its + // referent IS a key of $classConcrete. The guard is defensive against a non-erasable call. + if ($param->bound instanceof BoundLeaf && isset($classConcrete[$param->bound->type->name])) { + $subst[$param->name] = $classConcrete[$param->bound->type->name]; + } + } + + $lowered = $this->specializeMethod($method, $subst, $mangled); + self::rewriteErasableSelfCalls($lowered, $erasedNames); + + return $lowered; + } + + /** + * Rewrite `$this->m::<...>()` (and the nullsafe form) where `m` is a fellow erasable method to its + * E-mangled name, stripping the now-meaningless turbofish. The forwarded type argument is erased, + * so the call keys on the enclosing class's `E` exactly like every other call to `m`. + * + * @param array $erasedNames + */ + private static function rewriteErasableSelfCalls(ClassMethod $method, array $erasedNames): void + { + $traverser = new NodeTraverser(); + $traverser->addVisitor(new class($erasedNames) extends NodeVisitorAbstract { + /** @param array $erasedNames */ + public function __construct(private array $erasedNames) + { + } + + public function leaveNode(Node $node): ?Node + { + if (!$node instanceof MethodCall && !$node instanceof NullsafeMethodCall) { + return null; + } + // @infection-ignore-all — defensive receiver-shape guard: only a literal `$this->m()` + // with an Identifier name is rewritten. The alternate (`&&`) forms would attempt to + // process non-`$this` / dynamic-name calls, which an erasable method body never pairs + // with an erasable target — the `erasedNames` lookup below would miss them regardless. + if (!$node->var instanceof Variable + || $node->var->name !== 'this' + || !$node->name instanceof Identifier + ) { + return null; + } + $mangled = $this->erasedNames[$node->name->toString()] ?? null; + if ($mangled === null) { + return null; + } + $node->name = new Identifier($mangled, $node->name->getAttributes()); + // @infection-ignore-all — clearing the now-stale turbofish attribute is hygiene only: + // the pretty-printer never emits ATTR_METHOD_GENERIC_ARGS, and no pass reads it on an + // already-specialized class, so its removal is unobservable in the output. + $node->setAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS, null); + return $node; + } + }); + $traverser->traverse([$method]); + } + /** * Specialize a single generic method: clone the ClassMethod, substitute every * Name reference to a type-param with the matching concrete TypeRef, drop the diff --git a/test/Transpiler/Monomorphize/EnclosingBoundErasureTest.php b/test/Transpiler/Monomorphize/EnclosingBoundErasureTest.php index 1bf68aa..752662a 100644 --- a/test/Transpiler/Monomorphize/EnclosingBoundErasureTest.php +++ b/test/Transpiler/Monomorphize/EnclosingBoundErasureTest.php @@ -41,6 +41,7 @@ public function unionSecond(U $a, U|Fruit $b): bool { return true; } public function returnsConcrete(U $x): Fruit { return new Fruit(); } public function pairMixed(U $a, Box $b): bool { return true; } public function unionConcrete(U $a, Fruit|Banana $b): bool { return true; } + public function mixedBounds(U $a, W $b): bool { return true; } } PHP; @@ -174,6 +175,35 @@ public function testConcreteUnionSecondParamStaysErasable(): void self::assertTrue($this->erasable('unionConcrete')); } + public function testMixedBoundedAndNonBoundedParamsIsNotErasable(): void + { + // `` — W needs real per-W specialization, so the method is not erasable. + self::assertFalse($this->erasable('mixedBounds')); + } + + public function testMangleArgsKeysOnTheBoundsConcreteValue(): void + { + $fruit = new TypeRef('App\\Fruit'); + $classConcrete = ['E' => $fruit]; + + // → [Fruit] + $u = new TypeParam('U', new BoundLeaf(new TypeRef('E', isTypeParam: true))); + self::assertEquals([$fruit], EnclosingBoundErasure::mangleArgs([$u], $classConcrete)); + + // → [Fruit, Fruit] + $v = new TypeParam('V', new BoundLeaf(new TypeRef('E', isTypeParam: true))); + self::assertEquals([$fruit, $fruit], EnclosingBoundErasure::mangleArgs([$u, $v], $classConcrete)); + + // a sibling/method-bounded param (not a class param) contributes nothing. + $w = new TypeParam('W', new BoundLeaf(new TypeRef('U', isTypeParam: true))); + self::assertEquals([$fruit], EnclosingBoundErasure::mangleArgs([$u, $w], $classConcrete)); + + // multi-class-param: on Map keyed on V's concrete. + $banana = new TypeRef('App\\Banana'); + $uv = new TypeParam('U', new BoundLeaf(new TypeRef('V', isTypeParam: true))); + self::assertEquals([$banana], EnclosingBoundErasure::mangleArgs([$uv], ['K' => new TypeRef('App\\Key'), 'V' => $banana])); + } + public function testRefTreeHasBoundedDirectly(): void { self::assertTrue(EnclosingBoundErasure::refTreeHasBounded(new TypeRef('U', isTypeParam: true), ['U'])); diff --git a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php index 10efb69..3d101bb 100644 --- a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php +++ b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php @@ -6,10 +6,12 @@ use PhpParser\ParserFactory; use PhpParser\PrettyPrinter\Standard as StandardPrinter; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; use RuntimeException; use XPHP\Diagnostics\Diagnostic; use XPHP\Diagnostics\DiagnosticCollector; +use XPHP\TestSupport\CompiledFixture; use XPHP\FileSystem\FileFinder\NativeFileFinder; use XPHP\FileSystem\FileReader\NativeFileReader; use XPHP\FileSystem\FileWriter\NativeFileWriter; @@ -682,16 +684,96 @@ function pick(array $boxes): void { self::assertContains(GenericMethodCompiler::CODE_UNDETERMINED_RECEIVER, $codes); } - // --- forwarded `$this` self-call: can't be specialized at the template (stop the silent break) --- + // --- forwarded `$this` self-call to an erasable method: lowered, not failed --- - public function testForwardedAbstractSelfCallIsUnspecializableAndHardFails(): void + private const BOX_FORWARDING = <<<'PHP' + { + public function contains(U $value): bool { return true; } + public function probe(U $value): bool { return $this->contains::($value); } + } + PHP; + + public function testErasableForwardingSelfCallIsLoweredNotHardFailed(): void { - // `probe` forwards its own type parameter to `$this->contains::()`. The forwarded `U` - // is abstract in the template and can't be specialized there; rather than silently emit a bare - // `$this->contains(...)` to a stripped method (a runtime fatal), this is a compile error. - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('forwards a type parameter'); - $this->compile([ + // `probe` forwards its parameter to `$this->contains::()` — both are erasable, so the + // Specializer lowers both to E-mangled members and rewrites the forward. This COMPILES (it was + // an interim hard-fail before erasure landed); the call site lowers to the mangled `probe_`. + $dist = $this->compile([ + 'Models.xphp' => self::MODELS, + 'Box.xphp' => self::BOX_FORWARDING, + 'Use.xphp' => <<<'PHP' + (); + $box->probe::(new Banana()); + PHP, + ]); + + self::assertStringContainsString('probe_', self::read($dist, 'Use.php')); + } + + #[RunInSeparateProcess] + public function testErasableForwardingRunsAtRuntime(): void + { + // The non-negotiable gate: execute the emitted output. A bare `$this->contains(...)` (the old + // silent break) would fatal "undefined method" when `probe` runs; that it returns proves the + // Specializer rewrote the forward to the emitted `contains_` member. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_forwarding/source', + 'erase-fwd', + ); + try { + $fixture->registerAutoload('App'); + require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_forwarding/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testInheritedErasableMemberResolvesAtRuntime(): void + { + // `contains` declared on a generic base, called on a subclass instantiation. The + // call-site name (keyed on the receiver's E threaded to the declaring base) must match the + // Specializer's emitted member on Base, inherited by ArrayList. Executed. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_inherited/source', + 'erase-inherit', + ); + try { + $fixture->registerAutoload('App'); + require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_inherited/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testErasureIsVarianceSafeOnTheCovariantChainAtRuntime(): void + { + // The variance gate: Box<+E> builds a covariant extends-chain; the distinct E-mangled + // contains_ members coexist with no LSP fatal, and a Box dispatches the inherited + // contains_. Proven by executing the real compiled output. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_covariant_chain/source', + 'erase-chain', + ); + try { + $fixture->registerAutoload('App'); + require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_covariant_chain/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + public function testNullsafeForwardedSelfCallIsAlsoRewritten(): void + { + // A nullsafe forward (`$this?->contains::()`) is rewritten the same as the plain form. + $dist = $this->compile([ 'Models.xphp' => self::MODELS, 'Box.xphp' => <<<'PHP' { public function contains(U $value): bool { return true; } - public function probe(U $value): bool { return $this->contains::($value); } + public function probe(U $value): bool { return $this?->contains::($value); } } PHP, 'Use.xphp' => <<<'PHP' @@ -707,33 +789,30 @@ public function probe(U $value): bool { return $this->contains::($valu declare(strict_types=1); namespace App; $box = new Box::(); + $box->probe::(new Banana()); PHP, ]); + + // Compiled with no unspecializable error; the call site lowered to the mangled `probe_`. + self::assertStringContainsString('probe_', self::read($dist, 'Use.php')); } - public function testForwardedAbstractSelfCallIsCollectedInCheckMode(): void + public function testErasableForwardingDoesNotReportUnspecializableInCheckMode(): void { $collector = $this->check([ 'Models.xphp' => self::MODELS, - 'Box.xphp' => <<<'PHP' - { - public function contains(U $value): bool { return true; } - public function probe(U $value): bool { return $this->contains::($value); } - } - PHP, + 'Box.xphp' => self::BOX_FORWARDING, 'Use.xphp' => <<<'PHP' (); + $box->probe::(new Banana()); PHP, ]); $codes = array_map(static fn (Diagnostic $d): string => $d->code, $collector->all()); - self::assertContains(GenericMethodCompiler::CODE_UNSPECIALIZABLE_SELF_CALL, $codes); + self::assertNotContains(GenericMethodCompiler::CODE_UNSPECIALIZABLE_SELF_CALL, $codes); } public function testUnboundedForwardedSelfCallAlsoHardFails(): void diff --git a/test/fixture/compile/enclosing_bound_erasure_covariant_chain/source/Banana.xphp b/test/fixture/compile/enclosing_bound_erasure_covariant_chain/source/Banana.xphp new file mode 100644 index 0000000..81df12e --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_covariant_chain/source/Banana.xphp @@ -0,0 +1,9 @@ + +{ + public function contains(U $value): bool + { + return $value instanceof Fruit; + } +} diff --git a/test/fixture/compile/enclosing_bound_erasure_covariant_chain/source/Food.xphp b/test/fixture/compile/enclosing_bound_erasure_covariant_chain/source/Food.xphp new file mode 100644 index 0000000..2e9414a --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_covariant_chain/source/Food.xphp @@ -0,0 +1,9 @@ + $b): bool +{ + // Mangles on the receiver's E (Fruit) → contains_. + return $b->contains::(new Banana()); +} + +$food = new Box::(); +$fruit = new Box::(); +$banana = new Box::(); + +// A Box passed where Box is expected (covariant edge): the inherited +// contains_ dispatches on it. +$viaCovariance = check($banana); +$direct = $fruit->contains::(new Banana()); +$isCovariant = $banana instanceof Box; diff --git a/test/fixture/compile/enclosing_bound_erasure_covariant_chain/verify/runtime.php b/test/fixture/compile/enclosing_bound_erasure_covariant_chain/verify/runtime.php new file mode 100644 index 0000000..b1246d7 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_covariant_chain/verify/runtime.php @@ -0,0 +1,23 @@ + specializes into a covariant `extends` chain (Box extends Box extends + * Box). Each specialization carries its own E-mangled `contains_` and inherits its + * ancestors'; the distinct names mean no parameter-narrowing LSP fatal across the chain. A + * Box used where a Box is expected dispatches the inherited `contains_`. + * That this loads and runs proves erasure is variance-safe through the real pipeline. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertTrue($viaCovariance, 'a Box via the Box view runs the inherited contains_'); +Assert::assertTrue($direct); +Assert::assertTrue($isCovariant, 'Box must be an instanceof the Box marker'); diff --git a/test/fixture/compile/enclosing_bound_erasure_forwarding/source/Banana.xphp b/test/fixture/compile/enclosing_bound_erasure_forwarding/source/Banana.xphp new file mode 100644 index 0000000..81df12e --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_forwarding/source/Banana.xphp @@ -0,0 +1,9 @@ + +{ + public function contains(U $value): bool + { + return $value instanceof Fruit; + } + + public function probe(U $value): bool + { + // Forwards U to a fellow erasable method; the Specializer rewrites this to contains_. + return $this->contains::($value); + } +} diff --git a/test/fixture/compile/enclosing_bound_erasure_forwarding/source/Fruit.xphp b/test/fixture/compile/enclosing_bound_erasure_forwarding/source/Fruit.xphp new file mode 100644 index 0000000..0db3331 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_forwarding/source/Fruit.xphp @@ -0,0 +1,9 @@ +(); +$viaForward = $box->probe::(new Banana()); +$viaDirect = $box->contains::(new Banana()); diff --git a/test/fixture/compile/enclosing_bound_erasure_forwarding/verify/runtime.php b/test/fixture/compile/enclosing_bound_erasure_forwarding/verify/runtime.php new file mode 100644 index 0000000..aca0661 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_forwarding/verify/runtime.php @@ -0,0 +1,22 @@ +(U)` forwards its parameter to `$this->contains::()`. Both are erasable, so the + * Specializer lowers them to E-mangled members (`contains_`, `probe_`) on Box + * and rewrites the forward to call `contains_`. If the forward were left as a bare + * `$this->contains(...)` (the old silent break), this would fatal with "undefined method" the + * moment `probe` ran. That it runs and returns the contained-element verdict proves the lowering. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertTrue($viaForward, 'forwarded self-call must resolve to the emitted contains_ method'); +Assert::assertTrue($viaDirect, 'a direct erasable call must run too'); diff --git a/test/fixture/compile/enclosing_bound_erasure_inherited/source/ArrayList.xphp b/test/fixture/compile/enclosing_bound_erasure_inherited/source/ArrayList.xphp new file mode 100644 index 0000000..600d38c --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_inherited/source/ArrayList.xphp @@ -0,0 +1,9 @@ + extends Base +{ +} diff --git a/test/fixture/compile/enclosing_bound_erasure_inherited/source/Banana.xphp b/test/fixture/compile/enclosing_bound_erasure_inherited/source/Banana.xphp new file mode 100644 index 0000000..81df12e --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_inherited/source/Banana.xphp @@ -0,0 +1,9 @@ + +{ + public function contains(U $value): bool + { + return $value instanceof Fruit; + } +} diff --git a/test/fixture/compile/enclosing_bound_erasure_inherited/source/Fruit.xphp b/test/fixture/compile/enclosing_bound_erasure_inherited/source/Fruit.xphp new file mode 100644 index 0000000..0db3331 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_inherited/source/Fruit.xphp @@ -0,0 +1,9 @@ +` is declared on the generic BASE; the erased member is emitted on Base and +// inherited by ArrayList. The call must resolve to that inherited member. +$list = new ArrayList::(); +$inherited = $list->contains::(new Banana()); diff --git a/test/fixture/compile/enclosing_bound_erasure_inherited/verify/runtime.php b/test/fixture/compile/enclosing_bound_erasure_inherited/verify/runtime.php new file mode 100644 index 0000000..4fffa58 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_inherited/verify/runtime.php @@ -0,0 +1,21 @@ +` declared on a generic base is emitted as `contains_` on the + * Base specialization; ArrayList extends it and inherits the member. The call site, + * keyed on the receiver's E threaded to the DECLARING class, must produce the same mangled name — + * this is the cross-cutting mangling invariant where call-site and Specializer name computation could + * silently drift. That the call resolves and runs proves they agree. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertTrue($inherited, 'inherited erasable member must resolve via the same E-mangled name'); From 92520868fe8dc983250b1547dc5eb8cb2ab10797 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 24 Jun 2026 12:03:39 +0000 Subject: [PATCH 087/114] docs: forwarding an enclosing-bounded parameter now compiles and runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An earlier note said making a method generic and forwarding its parameter (`probe(U $v) { return $this->contains::($v); }`) does not work around the `$this`-self-call limitation. That is no longer true: a method whose enclosing-bounded parameter is used only as a direct input is lowered by erasing the parameter to its bound, so the forwarded self-call resolves to the emitted member and runs. Document the forwarding form as the idiomatic way to call an element-consuming method from within the class, and keep the accurate boundary — a direct concrete `$this->contains::()` and a non-erasable (nested / return-position) parameter still fail. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/syntax/type-bounds.md | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/docs/syntax/type-bounds.md b/docs/syntax/type-bounds.md index 50a2a88..2c47536 100644 --- a/docs/syntax/type-bounds.md +++ b/docs/syntax/type-bounds.md @@ -163,13 +163,32 @@ class Box<+E> { ``` Move such a call to a context where the receiver has a concrete element type -(e.g. a free function taking `Box $b`). Making the method itself generic -and forwarding its own parameter — `probe(U $v) { return -$this->contains::($v); }` — does **not** work around it: the `probe` call -site is checked, but the forwarded `$this->contains::()` is not re-specialized -when `probe` is, so it compiles to a `$this->contains(...)` call that fatals at -runtime. Until the per-instantiation check lands, keep an enclosing-parameter- -bounded call out of the class body entirely. +(e.g. a free function taking `Box $b`) — **or make the method itself +generic and forward the parameter:** + +```php +class Box<+E> { + public function contains(U $value): bool { /* ... */ } + + // ✅ Forwarding a method parameter compiles and runs: both methods take U + // only as a direct input, so each lowers to one `E`-typed member per + // instantiation and the forward resolves to it. + public function probe(U $value): bool { + return $this->contains::($value); + } +} +``` + +A method whose enclosing-bounded parameter is used **only** as a top-level +input (`U $value`) is lowered by erasing `U` to its bound `E`: one +`contains_(Fruit)` member per `Box`, rather than one per call-site +type. So a forwarded `$this->contains::()` rewrites to that member and runs. +The direct `$this->contains::()` above (a *concrete* turbofish on +`$this`) still fails — its bound is checked only on the abstract template — but +the forwarding form is the idiomatic way to call an element-consuming method +from within the class. A parameter used anywhere else (nested `Box`, a +return, `new U`) keeps the per-call lowering and a forwarded self-call to it is +still a compile error. ## Caveats From d93d64b247cc2df9efb6dce9f87baf715de9f8a7 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 24 Jun 2026 12:08:31 +0000 Subject: [PATCH 088/114] docs: document the unspecializable-self-call diagnostic and erasure lowering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweep the error catalog and changelog for the forwarded-self-call behavior. - errors.md: add `xphp.unspecializable_self_call` to the diagnostic table, the quick index, and the verbatim "Full error texts" section (with a worked non-erasable forward). Clarify that the bound-unprovable `$this` message is the direct, concrete self-call, and that forwarding a parameter to an erasable method compiles and runs. - CHANGELOG: note the lowering — an erasable method emits one E-typed member per instantiation, so a forwarded self-call compiles and runs, while a forward to a non-erasable target and a direct concrete self-call stay compile errors, never a runtime fault. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 10 +++++++++- docs/errors.md | 36 ++++++++++++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b114370..5b7f6dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 type argument genuinely can't be determined, the bound can't be proven, so it is a compile error (`xphp.bound_unprovable`) with an actionable remedy — bind the receiver to a typed local — rather than an unchecked call. Nothing knowable is skipped, and no check is deferred - to runtime. See [type bounds](docs/syntax/type-bounds.md) and + to runtime. **Lowering:** a method whose bounded parameter is used only as a direct input + (`U $value`) is emitted as one `E`-typed member per class instantiation + (`contains_(Fruit)`) rather than one per call-site type — so a self-call that *forwards* + the parameter, `probe(U $v) { return $this->contains::($v); }`, compiles and runs (the + idiomatic way to call an element-consuming method from inside the class). A `$this`-rooted + forward to a *non-erasable* method (parameter used nested, in the return, or structurally), and + a direct concrete `$this->contains::()`, remain compile errors + (`xphp.unspecializable_self_call` / `xphp.bound_unprovable`) — never a runtime fault. See + [type bounds](docs/syntax/type-bounds.md) and [ADR-0018](docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md). - **`xphp check`** — a validate-without-emitting CI gate. It runs every generic validation `xphp compile` does (bounds, variance, defaults, missing/duplicate diff --git a/docs/errors.md b/docs/errors.md index 69ea5f1..a707282 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -49,6 +49,7 @@ The `json` and `github` formats tag each diagnostic with a stable code: | `xphp.unresolved_generic_call` | a turbofish method call (`$obj->m::<…>()` / `Foo::m::<…>()`) names a generic method that can't be resolved on the receiver's type — a typo or wrong receiver type, caught at compile time instead of fataling at runtime | | `xphp.bound_unprovable` | a method-generic bound that references an enclosing class type parameter (`contains`) can't be proven because the receiver's type argument isn't determinable here — a raw `Box` with no argument, a branch whose arms disagree, a static call, or a `$this` self-call. Ground the receiver (bind it to a typed local) or the build fails | | `xphp.undetermined_receiver` | a turbofish method call's receiver has no statically-known type (an untyped `foreach` variable, a local whose type is ambiguous after a branch), so the call can't be specialized — it would emit a call to a stripped method that fatals at runtime. Give the receiver a declared type | +| `xphp.unspecializable_self_call` | a `$this`-rooted self-call forwards a type parameter to a **non-erasable** generic method (one whose parameter is used nested, in the return, or structurally). Forwarding to an *erasable* method — parameter used only as a direct input — compiles and runs; otherwise move the call to a typed-receiver context | | `xphp.parse_error` | the file isn't valid PHP after the generic strip pass | | `phpstan.*` | a PHPStan finding in the compiled output, mapped back to the template declaration (the code is `phpstan.` + PHPStan's own identifier, e.g. `phpstan.return.type`; a finding that carries no identifier falls back to the literal `phpstan.error`) — present only when the PHPStan pass runs | | `phpstan.unavailable` | (Warning) no phpstan binary was found, so the PHPStan pass was skipped | @@ -120,6 +121,7 @@ In CI (GitHub Actions), one step gates the build and annotates the diff: | `could not be resolved to a declared generic method` | A turbofish method call names a generic method that can't be resolved on the receiver's type — check the method name or the receiver's type. | | `Cannot verify generic bound` | [Type bounds — ground or fail](syntax/type-bounds.md#ground-or-fail) — the receiver's type argument isn't determinable; bind it to a typed local. | | `Cannot determine the receiver's type` | [Turbofish — receiver-type analysis](syntax/turbofish.md#receiver-type-analysis-instance-methods) — give the receiver a declared type. | +| `Cannot specialize the self-call` | [Type bounds — ground or fail](syntax/type-bounds.md#ground-or-fail) — the forward targets a non-erasable method; forward to an erasable one or move the call to a typed-receiver context. | | `was instantiated with N type argument(s) but parameter ... has no default` | [Defaults](syntax/defaults.md) — supply all required args or add defaults | | `Nested generic specialization exceeded depth` | A generic refers to itself transitively too deeply (compiler aborts at depth 16) — usually a recursive instantiation cycle. Refactor to break the cycle. | | `Parser returned null AST` | The source file isn't valid PHP after the generic strip pass. Run `php -l .xphp` mentally on the cleaned source — most often a syntax error in the user code that's unrelated to generics. | @@ -249,8 +251,38 @@ self-call: the bound references the enclosing class's own type parameter, which is abstract in the class template, so it can only be checked once the class is instantiated. Move this call to a context where the receiver has a concrete element type (e.g. a function taking `Box $b` then `$b->contains::<...>(...)`), -or don't turbofish an enclosing-parameter-bounded method on `$this`. (A future -per-instantiation bound check will relax this.) +or don't turbofish an enclosing-parameter-bounded method on `$this`. +``` + +This is the **direct, concrete** self-call (`$this->contains::()`). A +self-call that **forwards a method parameter** to an *erasable* method — +`probe(U $v) { return $this->contains::($v); }` — compiles and runs (see +[type bounds](syntax/type-bounds.md)); only a forward to a *non-erasable* target +fails (next). + +### Unspecializable forwarded self-call + +A `$this`-rooted self-call that forwards a type parameter compiles when the +target is *erasable* (its parameter is used only as a direct input) — both +methods lower to one `E`-typed member per instantiation and the forward resolves +to it. When the target is **not** erasable (the parameter appears nested, in the +return, or structurally), the forward can't be specialized: + +```php +class Box<+E> { + public function nested(Box $items): bool { /* ... */ } // not erasable (U nested) + public function relay(Box $items): bool { + return $this->nested::($items); // forwards to a non-erasable target + } +} +``` + +``` +Cannot specialize the self-call `$this->nested::<...>()`: it forwards a type +parameter to a generic method, which has no concrete value in the class template. +Move the call to a context where the receiver has a concrete element type (e.g. a +function taking a typed `Box`), or call the method on a directly-constructed +value. ``` ### Undeterminable turbofish receiver From 85c9a252692b910ed106344a4b456cc87ee1cd56 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 24 Jun 2026 15:25:33 +0000 Subject: [PATCH 089/114] test(monomorphize): runtime gates for multi-class-param and param-widening erasure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two #[RunInSeparateProcess] runtime fixtures that execute the compiled output, closing the last uncovered erasure shapes: - `enclosing_bound_erasure_map_multiparam` — `containsValue` on `Map` is bounded by the SECOND class parameter, so the erased member must mangle on V (Fruit), not K (string). Proves the call-site and Specializer agree on the bound's-referent key for a multi-class-param class. - `enclosing_bound_erasure_param_widening` — `contains::` and `contains::` on the same Box both lower to one `contains_(Fruit)` member, widened to the bound. Proves the per-E collapse and the parameter widening at runtime. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../EnclosingParamBoundIntegrationTest.php | 34 +++++++++++++++++++ .../source/Banana.xphp | 9 +++++ .../source/Fruit.xphp | 9 +++++ .../source/Map.xphp | 19 +++++++++++ .../source/Use.xphp | 9 +++++ .../verify/runtime.php | 21 ++++++++++++ .../source/Banana.xphp | 9 +++++ .../source/Box.xphp | 13 +++++++ .../source/Cherry.xphp | 9 +++++ .../source/Fruit.xphp | 9 +++++ .../source/Use.xphp | 11 ++++++ .../verify/runtime.php | 21 ++++++++++++ 12 files changed, 173 insertions(+) create mode 100644 test/fixture/compile/enclosing_bound_erasure_map_multiparam/source/Banana.xphp create mode 100644 test/fixture/compile/enclosing_bound_erasure_map_multiparam/source/Fruit.xphp create mode 100644 test/fixture/compile/enclosing_bound_erasure_map_multiparam/source/Map.xphp create mode 100644 test/fixture/compile/enclosing_bound_erasure_map_multiparam/source/Use.xphp create mode 100644 test/fixture/compile/enclosing_bound_erasure_map_multiparam/verify/runtime.php create mode 100644 test/fixture/compile/enclosing_bound_erasure_param_widening/source/Banana.xphp create mode 100644 test/fixture/compile/enclosing_bound_erasure_param_widening/source/Box.xphp create mode 100644 test/fixture/compile/enclosing_bound_erasure_param_widening/source/Cherry.xphp create mode 100644 test/fixture/compile/enclosing_bound_erasure_param_widening/source/Fruit.xphp create mode 100644 test/fixture/compile/enclosing_bound_erasure_param_widening/source/Use.xphp create mode 100644 test/fixture/compile/enclosing_bound_erasure_param_widening/verify/runtime.php diff --git a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php index 3d101bb..f980675 100644 --- a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php +++ b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php @@ -734,6 +734,40 @@ public function testErasableForwardingRunsAtRuntime(): void } } + #[RunInSeparateProcess] + public function testMultiClassParamErasureMangleKeysOnTheBoundsReferentAtRuntime(): void + { + // `containsValue` on `Map` mangles on V (Fruit), not K (string). Call-site and + // Specializer must agree on that key, or the call resolves to nothing. Executed. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_map_multiparam/source', + 'erase-map', + ); + try { + $fixture->registerAutoload('App'); + require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_map_multiparam/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testTwoTurbofishTypesCollapseToOneWidenedMemberAtRuntime(): void + { + // The core erasure semantic: `contains::` and `contains::` both lower to one + // `contains_(Fruit)` member, widened to the bound, accepting each subtype. Executed. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_param_widening/source', + 'erase-widen', + ); + try { + $fixture->registerAutoload('App'); + require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_param_widening/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + #[RunInSeparateProcess] public function testInheritedErasableMemberResolvesAtRuntime(): void { diff --git a/test/fixture/compile/enclosing_bound_erasure_map_multiparam/source/Banana.xphp b/test/fixture/compile/enclosing_bound_erasure_map_multiparam/source/Banana.xphp new file mode 100644 index 0000000..81df12e --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_map_multiparam/source/Banana.xphp @@ -0,0 +1,9 @@ + +{ + public function describe(K $key): string + { + return (string) $key; + } + + // Bound references the SECOND class parameter V — the erased member must mangle on V, not K. + public function containsValue(U $value): bool + { + return $value instanceof Fruit; + } +} diff --git a/test/fixture/compile/enclosing_bound_erasure_map_multiparam/source/Use.xphp b/test/fixture/compile/enclosing_bound_erasure_map_multiparam/source/Use.xphp new file mode 100644 index 0000000..c6270f7 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_map_multiparam/source/Use.xphp @@ -0,0 +1,9 @@ +(); +$found = $map->containsValue::(new Banana()); +$label = $map->describe('k'); diff --git a/test/fixture/compile/enclosing_bound_erasure_map_multiparam/verify/runtime.php b/test/fixture/compile/enclosing_bound_erasure_map_multiparam/verify/runtime.php new file mode 100644 index 0000000..6ea0515 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_map_multiparam/verify/runtime.php @@ -0,0 +1,21 @@ +` on `Map` is bounded by the SECOND class parameter V. The erased member + * must mangle on V's concrete value (Fruit), not K (string) — the call site (keyed on the receiver's + * V) and the Specializer must agree on that key. That the call resolves and runs proves the + * multi-class-param mangle keys on the bound's referent. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertTrue($found, 'containsValue mangled on V (Fruit) must resolve and run'); +Assert::assertSame('k', $label); diff --git a/test/fixture/compile/enclosing_bound_erasure_param_widening/source/Banana.xphp b/test/fixture/compile/enclosing_bound_erasure_param_widening/source/Banana.xphp new file mode 100644 index 0000000..81df12e --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_param_widening/source/Banana.xphp @@ -0,0 +1,9 @@ + +{ + public function contains(U $value): bool + { + return $value instanceof Fruit; + } +} diff --git a/test/fixture/compile/enclosing_bound_erasure_param_widening/source/Cherry.xphp b/test/fixture/compile/enclosing_bound_erasure_param_widening/source/Cherry.xphp new file mode 100644 index 0000000..466d19e --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_param_widening/source/Cherry.xphp @@ -0,0 +1,9 @@ + collapse to ONE erased member +// contains_(Fruit): the parameter is widened to the bound E, so it accepts both subtypes. +$box = new Box::(); +$banana = $box->contains::(new Banana()); +$cherry = $box->contains::(new Cherry()); diff --git a/test/fixture/compile/enclosing_bound_erasure_param_widening/verify/runtime.php b/test/fixture/compile/enclosing_bound_erasure_param_widening/verify/runtime.php new file mode 100644 index 0000000..bbb51c8 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_param_widening/verify/runtime.php @@ -0,0 +1,21 @@ +` lowers to ONE `contains_(Fruit)` member per Box, and + * the parameter is widened to the bound E (Fruit). So two distinct call-site turbofish types + * (Banana, Cherry) both lower to that single member, which accepts each as a Fruit. Both calls + * running proves the per-E collapse and the param widening. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertTrue($banana, 'contains:: runs on the widened Fruit-typed member'); +Assert::assertTrue($cherry, 'contains:: runs on the SAME widened member'); From 9dea3fccac59baad90ba7dd50d1f7be99f214868 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 24 Jun 2026 15:27:32 +0000 Subject: [PATCH 090/114] test(monomorphize): runtime gate for a two-bounded-param erasable method `bothAreFruit(U, V)` erases both parameters to E and mangles on [E, E]; both widen to the bound, so a call with two distinct turbofish types resolves to the one emitted member and runs. Executes the compiled output. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../EnclosingParamBoundIntegrationTest.php | 17 +++++++++++++++++ .../source/Banana.xphp | 9 +++++++++ .../source/Box.xphp | 14 ++++++++++++++ .../source/Cherry.xphp | 9 +++++++++ .../source/Fruit.xphp | 9 +++++++++ .../source/Use.xphp | 8 ++++++++ .../verify/runtime.php | 19 +++++++++++++++++++ 7 files changed, 85 insertions(+) create mode 100644 test/fixture/compile/enclosing_bound_erasure_two_params/source/Banana.xphp create mode 100644 test/fixture/compile/enclosing_bound_erasure_two_params/source/Box.xphp create mode 100644 test/fixture/compile/enclosing_bound_erasure_two_params/source/Cherry.xphp create mode 100644 test/fixture/compile/enclosing_bound_erasure_two_params/source/Fruit.xphp create mode 100644 test/fixture/compile/enclosing_bound_erasure_two_params/source/Use.xphp create mode 100644 test/fixture/compile/enclosing_bound_erasure_two_params/verify/runtime.php diff --git a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php index f980675..90c0ae1 100644 --- a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php +++ b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php @@ -734,6 +734,23 @@ public function testErasableForwardingRunsAtRuntime(): void } } + #[RunInSeparateProcess] + public function testTwoEnclosingBoundedParamsEraseAndRunAtRuntime(): void + { + // A method with two enclosing-bounded params (``) erases both to E (mangles on + // [E, E]); both widen to the bound, so `bothAreFruit::` resolves and runs. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_two_params/source', + 'erase-two', + ); + try { + $fixture->registerAutoload('App'); + require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_two_params/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + #[RunInSeparateProcess] public function testMultiClassParamErasureMangleKeysOnTheBoundsReferentAtRuntime(): void { diff --git a/test/fixture/compile/enclosing_bound_erasure_two_params/source/Banana.xphp b/test/fixture/compile/enclosing_bound_erasure_two_params/source/Banana.xphp new file mode 100644 index 0000000..81df12e --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_two_params/source/Banana.xphp @@ -0,0 +1,9 @@ + +{ + // Two enclosing-bounded method params: both erase to E; the member mangles on [E, E]. + public function bothAreFruit(U $a, V $b): bool + { + return $a instanceof Fruit && $b instanceof Fruit; + } +} diff --git a/test/fixture/compile/enclosing_bound_erasure_two_params/source/Cherry.xphp b/test/fixture/compile/enclosing_bound_erasure_two_params/source/Cherry.xphp new file mode 100644 index 0000000..466d19e --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_two_params/source/Cherry.xphp @@ -0,0 +1,9 @@ +(); +$both = $box->bothAreFruit::(new Banana(), new Cherry()); diff --git a/test/fixture/compile/enclosing_bound_erasure_two_params/verify/runtime.php b/test/fixture/compile/enclosing_bound_erasure_two_params/verify/runtime.php new file mode 100644 index 0000000..1b3f650 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_erasure_two_params/verify/runtime.php @@ -0,0 +1,19 @@ +`) erases both to E and mangles on + * `[E, E]`. Both parameters widen to the bound (Fruit), so `bothAreFruit::` resolves + * to the one emitted member and runs. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertTrue($both, 'a two-bounded-param erasable method must resolve and run'); From 26525a8161184851ee95e652c406a9da4945ec69 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 24 Jun 2026 15:45:43 +0000 Subject: [PATCH 091/114] refactor(monomorphize): gate erasable-method retention without a continue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The strip loop kept an erasable method via `if (isErasable) continue;`, whose `continue→break` mutant escaped (a break would stop stripping every later template). Replace it with `if (!isErasableMethodOnClass(...)) stripMethod(...)` extracted to a pure helper — same behavior, no loop-control to flip, and the retain/strip decision is now mutation-killed by the runtime fixtures (an erasable method wrongly stripped breaks forwarding; a non-erasable one wrongly kept leaks into output). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/GenericMethodCompiler.php | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 62bbd94..d194dd0 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -237,17 +237,9 @@ public function process(array &$astSet, bool $emit = true): void // An erasable `` method is KEPT on its (generic) class so the Specializer can erase // it into a concrete `contains_T_(E)` member per instantiation. The generic class is // lowered to a marker interface in the user file, so the kept template never reaches output. - $methodParams = $template->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); - $classParams = $class->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); - if (is_array($methodParams) && is_array($classParams)) { - /** @var list $methodParams */ - /** @var list $classParams */ - $classParamNames = array_map(static fn (TypeParam $p): string => $p->name, $classParams); - if (EnclosingBoundErasure::isErasable($template, $methodParams, $classParamNames)) { - continue; - } + if (!self::isErasableMethodOnClass($template, $class)) { + $this->stripMethod($class, $methodName); } - $this->stripMethod($class, $methodName); } // Strip the original function templates: namespaced ones get stripped from @@ -2402,6 +2394,23 @@ private function finalizeClosureDispatchers(object $visitor, int $hashLength): v } } + /** + * Whether a generic-method template on `$class` is erasable (``, `U` direct-input only) — + * in which case it is kept for the Specializer to lower per instantiation rather than stripped. + */ + private static function isErasableMethodOnClass(ClassMethod $template, ClassLike $class): bool + { + $methodParams = $template->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + $classParams = $class->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + if (!is_array($methodParams) || !is_array($classParams)) { + return false; + } + /** @var list $methodParams */ + /** @var list $classParams */ + $classParamNames = array_map(static fn (TypeParam $p): string => $p->name, $classParams); + return EnclosingBoundErasure::isErasable($template, $methodParams, $classParamNames); + } + private function stripMethod(ClassLike $class, string $methodName): void { $newStmts = []; From 886f8f426840e3a044726bb82b4e3949b79ad874 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 24 Jun 2026 15:54:41 +0000 Subject: [PATCH 092/114] docs: bring the ADR and roadmap current with the erasure lowering The grounding ADR and the roadmap were written before the erasure lowering landed and still framed every `$this` self-call as a hard error awaiting a future per-instantiation check. Correct that: - ADR-0018: record the erasure lowering (a direct-input `` method emits one E-typed member per instantiation) and that a forwarded self-call to such a method compiles and runs. Scope the remaining failures to the direct-concrete self-call (`xphp.bound_unprovable`) and the forward-to-non-erasable case (`xphp.unspecializable_self_call`); update the confirmation accordingly. - roadmap: add the erasure/forwarding capability to Shipped; narrow the open per-instantiation item to the direct-concrete self-call. - type-bounds: scope the "loud limitation" intro to the direct-concrete case so it no longer reads as covering the forwarding form documented just below. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ric-bounds-on-enclosing-type-parameters.md | 29 +++++++++++++------ docs/roadmap.md | 12 ++++++-- docs/syntax/type-bounds.md | 6 ++-- 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md b/docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md index da6b3ca..35f4de4 100644 --- a/docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md +++ b/docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md @@ -82,13 +82,21 @@ the emitted code carries nothing). instance to ground `E` against — so a static method whose bound names a class parameter is unprovable and fails. (The call's own method-parameter bounds, e.g. ``, still ground against the turbofish arguments and are checked.) -- **`$this` self-calls fail, for now.** A `$this->m::()` self-call inside the class body - references the class's *own* parameter, which is abstract until the class is instantiated; whether - the bound holds is instance-dependent (valid for `Box`, not for `Box`), and the bound - checker only runs on the abstract template. Rather than silently accept it, this fails with a - self-call-specific message. This is an intentionally loud, **temporary** limitation, not a permanent - erasure boundary: a future per-instantiation bound check can re-ground and check the self-call once - the enclosing class is specialised, turning the hard error into a real check (the safe direction). +- **Erasable methods are lowered, and a forwarded self-call works.** A method whose enclosing-bounded + parameter is used *only* as a direct top-level input (`U $value`) is lowered by **erasing `U` to its + bound `E`**: one concrete `E`-typed member per class instantiation (`contains_(Fruit)`), not + one per call-site turbofish (`contains_(Banana)`) — `` is, after all, the + variance-legal spelling of "an `E`-typed input". A `$this`-rooted self-call that *forwards* its + parameter to such a method — `probe(U $v) { return $this->contains::($v); }` — therefore + compiles and runs (the forward rewrites to the emitted member); it is the idiomatic way to call an + element-consuming method from inside the class. The bound is still checked at the call site before + erasure, so `Box::contains` is still rejected. +- **The residual `$this` self-calls still fail — loudly, never at runtime.** A *direct concrete* + `$this->contains::()` self-call (its bound is checkable only on the abstract template) fails + with `xphp.bound_unprovable`; a forward to a *non-erasable* method (parameter used nested, in the + return, or structurally) fails with `xphp.unspecializable_self_call`. Both are compile errors, never + a runtime fault. (A future per-instantiation re-check could relax the direct-concrete case too, but + the common forwarding shape is already handled by erasure.) - Boundary unchanged — grounding resolves the enclosing parameter to the receiver's argument; the grounded bound is then checked nominally/erased as before. F-bounded and generic-argument bound checking are unaffected. @@ -107,8 +115,11 @@ multi-argument (`Pair::containsValue`) accept that grounds the rig whose message shows the grounded bound, the determined-receiver cases (parameter / property / closure-`use` / method-return / chain / `self`-`static` / branch-arms-agree) accepting or rejecting on the grounded type, and the unprovable cases (a raw generic parameter, a branch whose arms disagree, a -static class-parameter bound, and a `$this` self-call) failing with `xphp.bound_unprovable` — both -thrown in `compile` and collected in `check`. A sibling-parameter bound (`class Pair`) is +static class-parameter bound, and a *direct concrete* `$this` self-call) failing with +`xphp.bound_unprovable` — both thrown in `compile` and collected in `check`. The erasure lowering is +exercised by executing the compiled output (a forwarding self-call, an inherited member, a covariant +chain, a multi-class-param `Map`), and a forward to a *non-erasable* method fails with +`xphp.unspecializable_self_call`. A sibling-parameter bound (`class Pair`) is unit-tested accept/reject with the grounded sibling shown, and a method-own sibling bound (``) is grounded against the turbofish arguments. The receiver-argument threading is unit-tested for chains, diamonds (agreeing → one grounding, conflicting → none), cycles, and arity gaps. The variance diff --git a/docs/roadmap.md b/docs/roadmap.md index e8e21bc..23becd2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -135,6 +135,10 @@ upcoming one. - Enclosing-parameter method bounds (`contains`) grounded against the receiver, or a compile error (`xphp.bound_unprovable`) when the element type can't be determined. +- Erasure lowering of a direct-input `` method (one `E`-typed member + per instantiation), so a forwarded self-call + (`probe{ $this->contains::(…) }`) compiles and runs; a forward to + a non-erasable method is a compile error (`xphp.unspecializable_self_call`). ### Anonymous templates @@ -255,9 +259,11 @@ to ship. - Branching narrowing precision: today a turbofish call on a receiver whose branch arms disagree is a compile error; could track unions with runtime dispatch instead. -- Per-instantiation checking of a `$this`-self-call's enclosing-parameter - bound (today a compile error), to accept the calls that hold for the - instantiations actually used. +- Per-instantiation checking of a **direct concrete** `$this`-self-call's + enclosing-parameter bound (`$this->contains::()`, today a compile + error), to accept the calls that hold for the instantiations actually used. + (The common *forwarding* shape already works via erasure lowering — see + Shipped.) ### Generic completeness diff --git a/docs/syntax/type-bounds.md b/docs/syntax/type-bounds.md index 2c47536..dc57d43 100644 --- a/docs/syntax/type-bounds.md +++ b/docs/syntax/type-bounds.md @@ -146,8 +146,10 @@ fails, with a message pointing at the fix. A *static* method whose bound names a class parameter fails the same way: a class type parameter has no value in a static context, so there is nothing to ground it to. -The `$this`-self-call case is an intentionally loud, temporary limitation — -its bound is provable per instantiation, just not yet checked there: +A **direct, concrete** `$this`-self-call (a hardcoded turbofish type on `$this`) +is an intentionally loud limitation — its bound is checkable only once the class +is instantiated, so for now it fails (the *forwarding* form below is the way to +make it compile and run): ```php class Box<+E> { From 87df3ae3ede6843a54bd6ff951cd1be4efee6507 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 24 Jun 2026 20:12:11 +0000 Subject: [PATCH 093/114] fix(monomorphize): keep a specialization's source parent when emitting a variance edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A covariant class with a source parent (`class ListColl<+E> extends Base`) instantiated at two or more args had its `extends` clause overwritten by the variance edge emitter with the same-template covariant super (`ListColl extends ListColl`). PHP allows a class only one parent, and the source parent carries the inherited member bodies — overwriting it severed `ListColl`'s path to its inherited `contains_` member and fataled with "undefined method" on the call. VarianceEdgeEmitter::addImplementsEdges now leaves a non-null `extends` intact: the source parent wins and the same-template covariant leaf edge is dropped (a missed `instanceof`, never a fatal — the covariant relationship still holds transitively through the parent-less base chain, which is where erased members are carried down). Previously masked because every variance fixture used a parent-less template. Adds a runtime fixture exercising two covariant specializations of a class with a generic parent, executing the compiled output to prove each keeps its inherited erased member. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/VarianceEdgeEmitter.php | 14 +++++++++++ .../EnclosingParamBoundIntegrationTest.php | 20 +++++++++++++++ .../source/Banana.xphp | 9 +++++++ .../source/Base.xphp | 13 ++++++++++ .../source/Fruit.xphp | 9 +++++++ .../source/ListColl.xphp | 9 +++++++ .../source/Use.xphp | 17 +++++++++++++ .../verify/runtime.php | 25 +++++++++++++++++++ 8 files changed, 116 insertions(+) create mode 100644 test/fixture/compile/variance_edge_preserves_source_parent/source/Banana.xphp create mode 100644 test/fixture/compile/variance_edge_preserves_source_parent/source/Base.xphp create mode 100644 test/fixture/compile/variance_edge_preserves_source_parent/source/Fruit.xphp create mode 100644 test/fixture/compile/variance_edge_preserves_source_parent/source/ListColl.xphp create mode 100644 test/fixture/compile/variance_edge_preserves_source_parent/source/Use.xphp create mode 100644 test/fixture/compile/variance_edge_preserves_source_parent/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/VarianceEdgeEmitter.php b/src/Transpiler/Monomorphize/VarianceEdgeEmitter.php index b4b824c..75c38dd 100644 --- a/src/Transpiler/Monomorphize/VarianceEdgeEmitter.php +++ b/src/Transpiler/Monomorphize/VarianceEdgeEmitter.php @@ -267,6 +267,20 @@ private static function addImplementsEdges(ClassLike $ast, array $directSupers): ); if ($ast instanceof Class_) { + // PHP allows a class exactly ONE parent. A specialized class that already carries a source + // `extends` (e.g. `class ListColl<+E> extends AbstractColl` → `ListColl_Book extends + // AbstractColl_Book`) must keep it: that parent carries the inherited member bodies and the + // source-declared `is-a` relationships. A same-template covariant super + // (`ListColl <: ListColl`) cannot ALSO be a direct parent under single + // inheritance, so we do not overwrite — the source parent wins, and the covariant *leaf* + // edge is dropped (a missed `instanceof`, never a fatal; the covariant relationship still + // holds transitively through the parent-less base chain, which is where erased members are + // carried down). Overwriting would sever the source parent and silently drop the inherited + // member — a class-load / undefined-method fatal. See also the specialization closer, which + // hard-fails the rarer case where the dropped edge would itself have carried an erased impl. + if ($ast->extends !== null) { + return; + } $ast->extends = new FullyQualified($directSupers[0]->generatedFqn); return; } diff --git a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php index 90c0ae1..994a7e5 100644 --- a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php +++ b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php @@ -821,6 +821,26 @@ public function testErasureIsVarianceSafeOnTheCovariantChainAtRuntime(): void } } + #[RunInSeparateProcess] + public function testVarianceEdgeDoesNotOverwriteASourceParentAtRuntime(): void + { + // A covariant class with a SOURCE parent (`ListColl<+E> extends Base`) instantiated at two + // args (Fruit, Banana). The variance edge emitter must keep each specialization's source + // `extends Base` rather than overwrite it with the same-template covariant super + // (`ListColl extends ListColl`) — overwriting would sever the inherited + // `contains_` member and fatal "undefined method". Proven by executing the output. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/variance_edge_preserves_source_parent/source', + 'variance-src-parent', + ); + try { + $fixture->registerAutoload('App'); + require __DIR__ . '/../../fixture/compile/variance_edge_preserves_source_parent/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + public function testNullsafeForwardedSelfCallIsAlsoRewritten(): void { // A nullsafe forward (`$this?->contains::()`) is rewritten the same as the plain form. diff --git a/test/fixture/compile/variance_edge_preserves_source_parent/source/Banana.xphp b/test/fixture/compile/variance_edge_preserves_source_parent/source/Banana.xphp new file mode 100644 index 0000000..81df12e --- /dev/null +++ b/test/fixture/compile/variance_edge_preserves_source_parent/source/Banana.xphp @@ -0,0 +1,9 @@ + +{ + public function contains(U $value): bool + { + return $value instanceof Fruit; + } +} diff --git a/test/fixture/compile/variance_edge_preserves_source_parent/source/Fruit.xphp b/test/fixture/compile/variance_edge_preserves_source_parent/source/Fruit.xphp new file mode 100644 index 0000000..0db3331 --- /dev/null +++ b/test/fixture/compile/variance_edge_preserves_source_parent/source/Fruit.xphp @@ -0,0 +1,9 @@ + extends Base +{ +} diff --git a/test/fixture/compile/variance_edge_preserves_source_parent/source/Use.xphp b/test/fixture/compile/variance_edge_preserves_source_parent/source/Use.xphp new file mode 100644 index 0000000..00aa10b --- /dev/null +++ b/test/fixture/compile/variance_edge_preserves_source_parent/source/Use.xphp @@ -0,0 +1,17 @@ + extends Base`. Because `ListColl <: ListColl` (covariant E, +// Banana extends Fruit), the variance edge emitter would, if it overwrote the source `extends`, +// replace `ListColl extends Base` with `extends ListColl` — severing the +// inherited `contains_` member (its body lives on Base). The direct call below +// would then fatal "undefined method". The source parent must be preserved. +$fruits = new ListColl::(); +$bananas = new ListColl::(); + +$fruitHit = $fruits->contains::(new Fruit()); +$bananaHit = $bananas->contains::(new Banana()); diff --git a/test/fixture/compile/variance_edge_preserves_source_parent/verify/runtime.php b/test/fixture/compile/variance_edge_preserves_source_parent/verify/runtime.php new file mode 100644 index 0000000..2a36e9f --- /dev/null +++ b/test/fixture/compile/variance_edge_preserves_source_parent/verify/runtime.php @@ -0,0 +1,25 @@ + extends Base` is instantiated at two covariant args (Fruit, Banana). The variance + * edge emitter must NOT overwrite each specialization's source `extends Base` with the same-template + * covariant super (`ListColl extends ListColl`): single inheritance allows one parent, + * and the source parent carries the inherited `contains_` member. Overwriting would drop + * `ListColl`'s path to `contains_` and fatal "undefined method" on the call below. + * + * That both calls resolve and run proves each specialization kept its source parent and its inherited + * erased member. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertTrue($fruitHit, 'ListColl must inherit contains_ from its source parent Base'); +Assert::assertTrue($bananaHit, 'ListColl must keep its source parent (not be overwritten) so contains_ resolves'); From e765009347ab40fab961393b9f3d23d332ae5ad6 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 24 Jun 2026 20:27:42 +0000 Subject: [PATCH 094/114] refactor(monomorphize): extract the variance-subtype decision into a shared helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-arg variance-subtype rule (invariant: equal; covariant/contravariant: nested subtype, recursing through an inner same-template's variance) is moved out of VarianceEdgeEmitter into a standalone VarianceSubtyping collaborator so it can be reused without dragging in edge-emission concerns. Pure refactor — the emitter delegates and its behaviour is unchanged. Adds VarianceSubtypingTest with in-process accept/reject pairs (covariant strict subtype, contravariant reversal, invariant equality, identical-arg rejection, arity guards, and the nested same-template recursion). This logic was previously exercised only by separate-process runtime fixtures, so it now carries direct mutation coverage; the conservative both-generic guards and `ltrim` defensives are documented as equivalent. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/VarianceEdgeEmitter.php | 126 ++--------- .../Monomorphize/VarianceSubtyping.php | 132 ++++++++++++ .../Monomorphize/VarianceSubtypingTest.php | 195 ++++++++++++++++++ 3 files changed, 344 insertions(+), 109 deletions(-) create mode 100644 src/Transpiler/Monomorphize/VarianceSubtyping.php create mode 100644 test/Transpiler/Monomorphize/VarianceSubtypingTest.php diff --git a/src/Transpiler/Monomorphize/VarianceEdgeEmitter.php b/src/Transpiler/Monomorphize/VarianceEdgeEmitter.php index 75c38dd..d813209 100644 --- a/src/Transpiler/Monomorphize/VarianceEdgeEmitter.php +++ b/src/Transpiler/Monomorphize/VarianceEdgeEmitter.php @@ -17,27 +17,20 @@ * this adds `extends Producer_Fruit_` to the cloned `Producer_Banana` * Class_ node. * - * Three rules per arg-pair `(arg_i, variance_i)`: - * - Invariant: arg1.canonical() == arg2.canonical() - * - Covariant: isNestedSubtype(arg1, arg2) - * - Contravariant: isNestedSubtype(arg2, arg1) - * - * Scalar args are skipped -- there's no PHP-level subtype relationship - * between scalars, so emitting an edge would PHP-fatal at autoload. - * - * `isNestedSubtype` handles both leaf (non-generic) classes and nested - * generic instantiations of the same template (recurses through the inner - * template's variance). Different templates or mixed forms return false - * conservatively -- a wrong edge would PHP-fatal at autoload, while a - * missed edge only loses an `instanceof` relationship the compiler couldn't - * prove. The bound check (`Registry::evaluateBound`) treats null verdicts - * as "reject"; the variance check treats them as "skip edge" for that same - * fatal-vs-missed-edge asymmetry. + * The subtype relationship per arg-pair (invariant: equal; covariant/contravariant: + * nested subtype) is decided by {@see VarianceSubtyping}, shared with the + * specialization closer. A wrong edge would PHP-fatal at autoload, while a missed + * edge only loses an `instanceof` relationship the compiler couldn't prove — so the + * subtype check is conservative (the bound check `Registry::evaluateBound` treats null + * verdicts as "reject"; the variance check treats them as "skip edge" for that same + * fatal-vs-missed-edge asymmetry). * * Edge shape: * - Class_ specialization -> `extends ` (single, since PHP allows - * only one class inheritance). If multiple "direct" supers exist - * (unrelated diamond), the lexicographically-first generated FQN wins + * only one class inheritance), and ONLY when the specialization has no source + * `extends` of its own — a class with a source parent keeps it (single inheritance; + * overwriting would sever the inherited member bodies). If multiple "direct" supers + * exist (unrelated diamond), the lexicographically-first generated FQN wins * deterministically. * - Interface_ specialization -> `extends , , ...` * (multi-target; PHP interfaces support it). @@ -51,8 +44,11 @@ */ final class VarianceEdgeEmitter { - public function __construct(private readonly TypeHierarchy $hierarchy) + private readonly VarianceSubtyping $subtyping; + + public function __construct(TypeHierarchy $hierarchy) { + $this->subtyping = new VarianceSubtyping($hierarchy); } /** @@ -88,7 +84,7 @@ public function emitEdges(array $specializedAsts, Registry $registry): void if ($sp1->generatedFqn === $sp2->generatedFqn) { continue; } - if ($this->isVarianceSubtype( + if ($this->subtyping->isVarianceSubtype( $sp1->concreteTypes, $sp2->concreteTypes, $definition->typeParams, @@ -124,7 +120,7 @@ private function filterDirectSupers(array $candidates, array $params, Registry $ } // sp3 is a more-specific super than sp2 if sp3 <: sp2 -- then // sp2 is reached transitively via sp3, so sp2 isn't direct. - if ($this->isVarianceSubtype( + if ($this->subtyping->isVarianceSubtype( $sp3->concreteTypes, $sp2->concreteTypes, $params, @@ -154,94 +150,6 @@ private static function hasNonInvariantParam(array $params): bool return false; } - /** - * @param list $args1 - * @param list $args2 - * @param list $params - */ - private function isVarianceSubtype( - array $args1, - array $args2, - array $params, - Registry $registry, - ): bool { - if (count($args1) !== count($args2) || count($args1) !== count($params)) { - return false; - } - $sawNonIdentity = false; - foreach ($args1 as $i => $a1) { - $a2 = $args2[$i]; - $variance = $params[$i]->variance; - - if ($a1->canonical() !== $a2->canonical()) { - $sawNonIdentity = true; - } - - // Scalar args never participate in variance edges. - if ($a1->isScalar || $a2->isScalar) { - if ($a1->canonical() !== $a2->canonical()) { - return false; - } - continue; - } - - if ($variance === Variance::Invariant) { - if ($a1->canonical() !== $a2->canonical()) { - return false; - } - continue; - } - if ($variance === Variance::Covariant) { - if (!$this->isNestedSubtype($a1, $a2, $registry)) { - return false; - } - continue; - } - // Contravariant - if (!$this->isNestedSubtype($a2, $a1, $registry)) { - return false; - } - } - // sp1 == sp2 (identical args) is filtered upstream, but defensively: - // require at least one arg to differ before claiming a subtype edge. - return $sawNonIdentity; - } - - /** - * Three-way subtype check tailored for variance edge emission. - * - * - Both non-generic: delegate to TypeHierarchy::isSubtype. - * - Both generic of the SAME template: recurse arg-wise through THAT - * template's variance. This is what makes `Producer>` - * and `Producer>` produce an edge when Box has covariant - * T -- without it, the comparison would flatten to - * `isSubtype('Box', 'Box') == true` and emit a wrong edge for the - * case where the INNER args aren't subtype-related. - * - Otherwise (different templates, or one generic one not): - * conservative false. A wrong edge would PHP-fatal at autoload. - */ - private function isNestedSubtype(TypeRef $child, TypeRef $parent, Registry $registry): bool - { - if (!$child->isGeneric() && !$parent->isGeneric()) { - return $this->hierarchy->isSubtype($child->name, $parent->name) === true; - } - if ($child->isGeneric() && $parent->isGeneric() - && ltrim($child->name, '\\') === ltrim($parent->name, '\\') - ) { - $innerDef = $registry->definition(ltrim($child->name, '\\')); - $innerParams = $innerDef !== null ? $innerDef->typeParams : []; - if ($innerParams === []) { - return false; - } - return $this->isVarianceSubtype( - $child->args, - $parent->args, - $innerParams, - $registry, - ); - } - return false; - } /** * Class_ specializations get a single `extends` (PHP allows one parent class). diff --git a/src/Transpiler/Monomorphize/VarianceSubtyping.php b/src/Transpiler/Monomorphize/VarianceSubtyping.php new file mode 100644 index 0000000..fdd5fbd --- /dev/null +++ b/src/Transpiler/Monomorphize/VarianceSubtyping.php @@ -0,0 +1,132 @@ + $args1 + * @param list $args2 + * @param list $params + */ + public function isVarianceSubtype(array $args1, array $args2, array $params, Registry $registry): bool + { + if (count($args1) !== count($args2) || count($args1) !== count($params)) { + return false; + } + $sawNonIdentity = false; + foreach ($args1 as $i => $a1) { + $a2 = $args2[$i]; + $variance = $params[$i]->variance; + + if ($a1->canonical() !== $a2->canonical()) { + $sawNonIdentity = true; + } + + // Scalar args never participate in variance edges. + // @infection-ignore-all LogicalOr -- `||` vs `&&` are equivalent here: a scalar is never a + // subtype of a class (and two scalars relate only by canonical equality), so whether ONE or + // BOTH args are scalar, the canonical-equality check below yields the same verdict. The + // mixed scalar/class case (one scalar) is rejected by both forms. + if ($a1->isScalar || $a2->isScalar) { + if ($a1->canonical() !== $a2->canonical()) { + return false; + } + continue; + } + + if ($variance === Variance::Invariant) { + if ($a1->canonical() !== $a2->canonical()) { + return false; + } + continue; + } + if ($variance === Variance::Covariant) { + if (!$this->isNestedSubtype($a1, $a2, $registry)) { + return false; + } + continue; + } + // Contravariant + if (!$this->isNestedSubtype($a2, $a1, $registry)) { + return false; + } + } + // Identical args (`sp1 == sp2`) is filtered upstream, but defensively require at least one + // differing arg before claiming a subtype edge. + return $sawNonIdentity; + } + + /** + * Three-way subtype check tailored for variance reasoning. + * + * - Both non-generic: delegate to {@see TypeHierarchy::isSubtype}. + * - Both generic of the SAME template: recurse arg-wise through THAT template's variance, so + * `Producer>` and `Producer>` relate when Box has covariant T — without + * it, the comparison would flatten to `isSubtype('Box', 'Box') == true` and claim a relationship + * even when the INNER args aren't subtype-related. + * - Otherwise (different templates, or one generic one not): conservative false. + * + * @infection-ignore-all LogicalAnd UnwrapLtrim -- the both-generic / same-template guard `&&`s are + * equivalent for every reachable input: instantiation args are always FULLY parameterized + * TypeRefs, so a parameterized-vs-bare or different-template pairing (the only inputs where `&&` + * and `||` would diverge) never arises — a mixed/different pairing is rejected either way + * (conservative false). The `ltrim('\\')` calls are no-ops because registry/canonical names never + * carry a leading backslash (same defensive the hierarchy collector documents), so unwrapping them + * is equivalent. The meaningful comparisons — the leaf `isSubtype(...) === true` and the inner + * recursion — stay mutation-covered by VarianceSubtypingTest. + */ + private function isNestedSubtype(TypeRef $child, TypeRef $parent, Registry $registry): bool + { + if (!$child->isGeneric() && !$parent->isGeneric()) { + return $this->hierarchy->isSubtype($child->name, $parent->name) === true; + } + if ($child->isGeneric() && $parent->isGeneric() + && ltrim($child->name, '\\') === ltrim($parent->name, '\\') + ) { + $innerDef = $registry->definition(ltrim($child->name, '\\')); + $innerParams = $innerDef !== null ? $innerDef->typeParams : []; + // @infection-ignore-all ReturnRemoval -- equivalent: removing this early return falls + // through to isVarianceSubtype() with empty params, whose arity guard + // (count(args) !== count(params)) returns false for the same non-empty args. Defensive. + if ($innerParams === []) { + return false; + } + return $this->isVarianceSubtype( + $child->args, + $parent->args, + $innerParams, + $registry, + ); + } + return false; + } +} diff --git a/test/Transpiler/Monomorphize/VarianceSubtypingTest.php b/test/Transpiler/Monomorphize/VarianceSubtypingTest.php new file mode 100644 index 0000000..81af792 --- /dev/null +++ b/test/Transpiler/Monomorphize/VarianceSubtypingTest.php @@ -0,0 +1,195 @@ + defined so the nested-generic recursion has a template to read. */ + private function subtyping(): VarianceSubtyping + { + return new VarianceSubtyping($this->hierarchy()); + } + + private function hierarchy(): TypeHierarchy + { + return new TypeHierarchy([self::BANANA => [self::FRUIT]]); + } + + private function registry(): Registry + { + $registry = new Registry(Registry::DEFAULT_HASH_HEX_LENGTH, $this->hierarchy()); + $registry->recordDefinition( + self::BOX, + 'Box', + [new TypeParam('T', variance: Variance::Covariant)], + new Class_('Box'), + 'test', + ); + return $registry; + } + + private static function covariant(): array + { + return [new TypeParam('T', variance: Variance::Covariant)]; + } + + public function testCovariantStrictSubtypeIsASubtype(): void + { + // Banana <: Fruit, covariant T: Producer <: Producer. Kills the `continue` + // mutant — without it the covariant pass would fall through to the contravariant check + // (Fruit <: Banana = false) and wrongly reject. + self::assertTrue($this->subtyping()->isVarianceSubtype( + [new TypeRef(self::BANANA)], + [new TypeRef(self::FRUIT)], + self::covariant(), + $this->registry(), + )); + } + + public function testCovariantSupertypeToSubtypeIsNotASubtype(): void + { + // The reverse direction: Producer is NOT <: Producer. Fruit is not a + // subtype of Banana, so no edge. + self::assertFalse($this->subtyping()->isVarianceSubtype( + [new TypeRef(self::FRUIT)], + [new TypeRef(self::BANANA)], + self::covariant(), + $this->registry(), + )); + } + + public function testContravariantReversesTheDirection(): void + { + // Contravariant T: Consumer <: Consumer (a Fruit-consumer can stand in for a + // Banana-consumer). Kills the contravariant-branch direction. + self::assertTrue($this->subtyping()->isVarianceSubtype( + [new TypeRef(self::FRUIT)], + [new TypeRef(self::BANANA)], + [new TypeParam('T', variance: Variance::Contravariant)], + $this->registry(), + )); + } + + public function testIdenticalArgsAreNotAProperSubtype(): void + { + // Reflexive pair: not a proper subtype edge. Kills the `$sawNonIdentity = false` init mutant — + // initialised true, identical args would be reported as a subtype. + self::assertFalse($this->subtyping()->isVarianceSubtype( + [new TypeRef(self::FRUIT)], + [new TypeRef(self::FRUIT)], + self::covariant(), + $this->registry(), + )); + } + + public function testCovariantRejectsWhenALaterArgIsNotASubtype(): void + { + // Two covariant params where the FIRST agrees but the SECOND does not: Fruit is not a subtype + // of Banana. The loop must keep checking after the first arg — kills the `continue` → `break` + // mutant, which would stop at arg 0 and wrongly accept on the (true) sawNonIdentity flag. + self::assertFalse($this->subtyping()->isVarianceSubtype( + [new TypeRef(self::BANANA), new TypeRef(self::FRUIT)], + [new TypeRef(self::FRUIT), new TypeRef(self::BANANA)], + [ + new TypeParam('T', variance: Variance::Covariant), + new TypeParam('U', variance: Variance::Covariant), + ], + $this->registry(), + )); + } + + public function testInvariantParamRequiresEqualArgs(): void + { + // Invariant T: Cell is NOT <: Cell even though Banana <: Fruit. + self::assertFalse($this->subtyping()->isVarianceSubtype( + [new TypeRef(self::BANANA)], + [new TypeRef(self::FRUIT)], + [new TypeParam('T', variance: Variance::Invariant)], + $this->registry(), + )); + } + + public function testArityMismatchBetweenArgsAndParamsIsNotASubtype(): void + { + // count(args) != count(params): the arity guard must reject. Kills the `||` → `&&` mutant, + // which would skip the guard and walk past the params array. + self::assertFalse($this->subtyping()->isVarianceSubtype( + [new TypeRef(self::BANANA)], + [new TypeRef(self::FRUIT)], + [], + $this->registry(), + )); + } + + public function testMismatchedArgCountsAreNotASubtype(): void + { + // count(args1) != count(args2): the other half of the arity guard. + self::assertFalse($this->subtyping()->isVarianceSubtype( + [new TypeRef(self::BANANA)], + [new TypeRef(self::BANANA), new TypeRef(self::FRUIT)], + self::covariant(), + $this->registry(), + )); + } + + public function testNestedSameTemplateGenericRecursesThroughInnerVariance(): void + { + // Producer> <: Producer> because Box has covariant T. Exercises the + // nested-generic branch (both generic, same template) and its recursion into Box's variance — + // kills the `&&` mutants in the same-template guard. + self::assertTrue($this->subtyping()->isVarianceSubtype( + [new TypeRef(self::BOX, [new TypeRef(self::BANANA)])], + [new TypeRef(self::BOX, [new TypeRef(self::FRUIT)])], + self::covariant(), + $this->registry(), + )); + } + + public function testNestedSameTemplateGenericRejectsWhenInnerArgsAreNotSubtype(): void + { + // Producer> is NOT <: Producer> — the inner recursion (Fruit <: Banana + // = false) rejects, which is the whole point of recursing instead of flattening to + // isSubtype('Box','Box'). + self::assertFalse($this->subtyping()->isVarianceSubtype( + [new TypeRef(self::BOX, [new TypeRef(self::FRUIT)])], + [new TypeRef(self::BOX, [new TypeRef(self::BANANA)])], + self::covariant(), + $this->registry(), + )); + } + + public function testDifferentInnerTemplatesAreNotSubtype(): void + { + // A nested generic of a template with no recorded definition can't be proven a subtype — the + // conservative false (empty inner params) path. + self::assertFalse($this->subtyping()->isVarianceSubtype( + [new TypeRef('App\\Other', [new TypeRef(self::BANANA)])], + [new TypeRef('App\\Other', [new TypeRef(self::FRUIT)])], + self::covariant(), + $this->registry(), + )); + } +} From a609565102618935ec499a1fe53c678b6f8d2a8d Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 24 Jun 2026 21:11:31 +0000 Subject: [PATCH 095/114] feat(monomorphize): schedule the implementer for a covariant upcast to an interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A method bounded by an enclosing type parameter (`interface Collection<+E> { contains(E2 $value): bool }`) lowers by erasing the parameter to its bound, emitting one member per class instantiation mangled on that class's own element type — so `Collection` declares the abstract `contains_(Book)` and `Collection` a distinct abstract `contains_(Product)`. (Distinct names are required: a shared name would override with a narrower parameter — a contravariant LSP fatal.) When a concrete `ListColl` (Book extends Product) was upcast to `Collection`, the covariant interface edge made it an instance of `Collection`, which demands a concrete `contains_`. Nothing in the Book chain implemented it, and the class that would (`AbstractColl`, inherited down the covariant chain) was never discovered — the fixed-point loop only follows substitution, and an upcast is a usage relationship. The emitted PHP then fataled at class load. A new SpecializationCloser runs inside the Phase 2 loop: for each interface specialization carrying an erased method and each concrete class that, by a strict covariant upcast, is an instance of it, it schedules the declaring class specialized at the supertype's arguments. The variance edge emitter then inherits the member. Where the implementation can't be carried that way — the declaring class has its own source parent that blocks the variance edge, the body is trait-only, or the arguments can't be threaded — it raises `xphp.unschedulable_covariant_upcast` rather than emit load-fataling code. Covered by runtime fixtures that upcast without instantiating the supertype (single- and multi-parameter) plus in-process tests for the scheduling, the no-upcast no-op, and the unschedulable hard-fail. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/Compiler.php | 10 +- .../Monomorphize/SpecializationCloser.php | 319 +++++++++++++++ .../EnclosingParamBoundIntegrationTest.php | 367 ++++++++++++++++++ .../source/AbstractColl.xphp | 21 + .../source/Book.xphp | 9 + .../source/Collection.xphp | 10 + .../source/ListColl.xphp | 9 + .../source/Product.xphp | 9 + .../source/Use.xphp | 17 + .../verify/runtime.php | 26 ++ .../source/AbstractMap.xphp | 21 + .../source/Book.xphp | 9 + .../source/HashMap.xphp | 9 + .../source/Id.xphp | 9 + .../source/MMap.xphp | 10 + .../source/Product.xphp | 9 + .../source/Use.xphp | 16 + .../verify/runtime.php | 21 + 18 files changed, 900 insertions(+), 1 deletion(-) create mode 100644 src/Transpiler/Monomorphize/SpecializationCloser.php create mode 100644 test/fixture/compile/enclosing_bound_interface_upcast/source/AbstractColl.xphp create mode 100644 test/fixture/compile/enclosing_bound_interface_upcast/source/Book.xphp create mode 100644 test/fixture/compile/enclosing_bound_interface_upcast/source/Collection.xphp create mode 100644 test/fixture/compile/enclosing_bound_interface_upcast/source/ListColl.xphp create mode 100644 test/fixture/compile/enclosing_bound_interface_upcast/source/Product.xphp create mode 100644 test/fixture/compile/enclosing_bound_interface_upcast/source/Use.xphp create mode 100644 test/fixture/compile/enclosing_bound_interface_upcast/verify/runtime.php create mode 100644 test/fixture/compile/enclosing_bound_interface_upcast_map/source/AbstractMap.xphp create mode 100644 test/fixture/compile/enclosing_bound_interface_upcast_map/source/Book.xphp create mode 100644 test/fixture/compile/enclosing_bound_interface_upcast_map/source/HashMap.xphp create mode 100644 test/fixture/compile/enclosing_bound_interface_upcast_map/source/Id.xphp create mode 100644 test/fixture/compile/enclosing_bound_interface_upcast_map/source/MMap.xphp create mode 100644 test/fixture/compile/enclosing_bound_interface_upcast_map/source/Product.xphp create mode 100644 test/fixture/compile/enclosing_bound_interface_upcast_map/source/Use.xphp create mode 100644 test/fixture/compile/enclosing_bound_interface_upcast_map/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index b8f33e0..b0c80f3 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -126,6 +126,7 @@ public function compile( // Phase 2: fixed-point specialization loop. /** @var array $specializedAsts keyed by generated FQCN */ $specializedAsts = []; + $closer = new SpecializationCloser($hierarchy, new VarianceSubtyping($hierarchy)); $depth = 0; while (true) { $countBefore = count($registry->instantiations()); @@ -155,8 +156,15 @@ public function compile( $collector->collect([$specialized], ""); } + // Close the specialization set under the covariant-upcast implementation requirement: a + // covariant upcast to an interface specialization carrying an erased (abstract) method + // needs the concrete supertype specialization that implements it, which the substitution + // walk above never discovers (an upcast is usage, not substitution). Schedules it here so + // the next iteration specializes it; the variance edge emitter then inherits the member. + $closerAdded = $closer->close($registry); + $countAfter = count($registry->instantiations()); - if (!$newlyProcessed) { + if (!$newlyProcessed && !$closerAdded) { break; } diff --git a/src/Transpiler/Monomorphize/SpecializationCloser.php b/src/Transpiler/Monomorphize/SpecializationCloser.php new file mode 100644 index 0000000..9d85146 --- /dev/null +++ b/src/Transpiler/Monomorphize/SpecializationCloser.php @@ -0,0 +1,319 @@ + { contains(E2 $value): bool }`) is lowered by erasing `E2` to + * its bound `E` — one member per class instantiation, mangled on that class's own `E`. So + * `Collection` declares the abstract `contains_(Book)` and `Collection` declares + * a DISTINCT abstract `contains_(Product)` (distinct names are required: a single shared + * name would make `contains(Book)` override `contains(Product)` with a narrower parameter — a + * contravariant LSP fatal). + * + * When a concrete `ListColl` (`Book <: Product`) is upcast to `Collection`, the + * covariant interface edge `Collection extends Collection` makes it an instance of + * `Collection`, which demands a concrete `contains_(Product)`. Nothing in the + * `Book` world implements it, and the class that would (`AbstractColl`, inherited down the + * covariant chain) is never discovered by the ordinary fixed-point loop — that loop only follows + * substitution, and a covariant upcast is a usage relationship. The result is emitted PHP that fatals + * at class load. + * + * This closer fills the gap: for every interface specialization that carries an erasable method, and + * every concrete class specialization that is — by a strict covariant upcast — an instance of it, it + * schedules the declaring class specialized at the supertype's arguments. The variance edge emitter + * then wires ` extends ` and the concrete member is inherited. Where the implementation + * cannot be carried that way (the declaring class itself has a source parent that would block the + * variance edge, the method body lives only in a trait, or the supertype arguments can't be threaded) + * it raises a compile error rather than emit code that would fatal at load — ground or fail. + * + * Runs inside the Compiler's Phase 2 fixed-point loop, off the registry alone (it needs the recorded + * instantiations + definitions, not the specialized ASTs). + */ +final readonly class SpecializationCloser +{ + /** Diagnostic code for an upcast whose concrete implementation cannot be scheduled. */ + public const CODE_UNSCHEDULABLE_UPCAST = 'xphp.unschedulable_covariant_upcast'; + + public function __construct( + private TypeHierarchy $hierarchy, + private VarianceSubtyping $subtyping, + ) { + } + + /** + * Schedule the concrete supertype specializations required by the covariant upcasts present in + * the registry. Returns true when it recorded at least one new instantiation, so the caller + * re-runs the fixed-point loop to specialize it. + */ + public function close(Registry $registry): bool + { + $countBefore = count($registry->instantiations()); + + /** @var list}> $interfaceSpecs */ + $interfaceSpecs = []; + /** @var list $concreteSpecs */ + $concreteSpecs = []; + foreach ($registry->instantiations() as $instantiation) { + $definition = $registry->definition($instantiation->templateFqn); + if ($definition === null) { + continue; + } + $ast = $definition->templateAst; + if ($ast instanceof Interface_) { + $erasable = $this->erasableMethodNames($definition); + if ($erasable !== []) { + $interfaceSpecs[] = [$instantiation, $erasable]; + } + } elseif ($ast instanceof Class_) { + // @infection-ignore-all -- the `!isAbstract()` filter is an optimization, not a + // correctness condition: an abstract class included here would only schedule the SAME + // declaring class the closer reaches via its concrete subclasses (idempotent), so every + // mutant of the negation produces the identical final registry. + if (!$ast->isAbstract()) { + $concreteSpecs[] = $instantiation; + } + } + } + + foreach ($interfaceSpecs as [$interfaceSpec, $methodNames]) { + foreach ($concreteSpecs as $concreteSpec) { + $this->closeOne($interfaceSpec, $methodNames, $concreteSpec, $registry); + } + } + + // @infection-ignore-all GreaterThan GreaterThanNegotiation -- this is the fixed-point signal + // for the caller's loop ("did I add anything?"). A too-permissive comparison (>= / <) reports + // "added" when nothing was, which the Compiler loop turns into non-termination, caught by the + // integration compiles (they would hang rather than return); the boundary itself isn't a + // separately assertable value. + return count($registry->instantiations()) > $countBefore; + } + + /** + * @param list $methodNames erasable method names declared on the interface + */ + private function closeOne( + GenericInstantiation $interfaceSpec, + array $methodNames, + GenericInstantiation $concreteSpec, + Registry $registry, + ): void { + $interfaceFqn = $interfaceSpec->templateFqn; + $concreteFqn = $concreteSpec->templateFqn; + + // The concrete class must implement the interface (the interface is an ancestor). + if (!in_array($interfaceFqn, $this->hierarchy->ancestorChain($concreteFqn), true)) { + return; + } + + // Thread the concrete receiver's args up to the interface: the interface's args as witnessed + // from this concrete spec. Null = unreachable / ambiguous (conflicting diamond paths). + $asSeen = $this->hierarchy->resolveInheritedArgs( + $concreteFqn, + $concreteSpec->concreteTypes, + $interfaceFqn, + ); + if ($asSeen === null) { + return; + } + + $interfaceDef = $registry->definition($interfaceFqn); + + // The concrete already implements THIS interface spec directly (no upcast): its own erased + // member already satisfies it — nothing to schedule. + // @infection-ignore-all -- this early return is an optimization the strict-upcast check below + // subsumes: when `$asSeen` equals the spec's args, isVarianceSubtype() returns false (no + // differing arg) and control returns anyway; `$interfaceDef === null` is unreachable (the spec + // entered $interfaceSpecs only after its definition resolved). Every mutant of this condition + // yields the same outcome; the strict-upcast path (and its non-subtype return) stays covered. + if ($interfaceDef === null || $this->argsEqual($asSeen, $interfaceSpec->concreteTypes)) { + return; + } + + // Is it a STRICT covariant upcast — ` <: `? Only then does + // the concrete inherit the interface spec's (distinctly-named) abstract erased member. + if (!$this->subtyping->isVarianceSubtype( + $asSeen, + $interfaceSpec->concreteTypes, + $interfaceDef->typeParams, + $registry, + )) { + return; + } + + foreach ($methodNames as $methodName) { + $this->scheduleImplementer($interfaceSpec, $concreteFqn, $methodName, $registry); + } + } + + /** + * Schedule the class that declares `$methodName`'s body, specialized at the interface spec's + * arguments, so the concrete subtype inherits a concrete member through the covariant chain. + */ + private function scheduleImplementer( + GenericInstantiation $interfaceSpec, + string $concreteFqn, + string $methodName, + Registry $registry, + ): void { + $declaring = $this->declaringClassWithBody($concreteFqn, $methodName, $registry); + if ($declaring === null) { + throw new RuntimeException($this->unschedulableMessage( + $interfaceSpec, + $methodName, + 'its implementation is not declared on a class in the hierarchy (a trait-supplied or ' + . 'interface-only body cannot be inherited through the covariant edge)', + )); + } + + $declaringDef = $registry->definition($declaring); + // @infection-ignore-all -- `?->` is defensive: declaringClassWithBody() returned $declaring + // precisely because registry->definition($declaring) resolved to a class definition carrying + // the method, so it is never null here. + $declaringAst = $declaringDef?->templateAst; + // If the declaring class itself has a source parent, the covariant edge that would carry the + // member (` extends `) cannot be emitted (PHP single + // inheritance — the source parent must win), so the member would not be inherited. Fail loudly. + if ($declaringAst instanceof Class_ && $declaringAst->extends !== null) { + throw new RuntimeException($this->unschedulableMessage( + $interfaceSpec, + $methodName, + sprintf( + 'its declaring class "%s" already extends another class, so the covariant edge that ' + . 'would inherit the member cannot be emitted (PHP allows one parent)', + $declaring, + ), + )); + } + + // Schedule the declaring class at the interface spec's arguments. This is valid only when the + // declaring class threads its parameters through to the interface identically — verify it. + // Schedule the declaring class at the interface spec's args only when it passes its parameters + // through to the interface UNCHANGED. A reordered/wrapped `implements` clause (the implementing + // spec can't be derived without inverting the mapping) or an unreachable target hard-fails — the + // closer never schedules a non-implementing specialization. (`$threaded === null` is the + // unreachable/ambiguous case; `!argsEqual` is the reorder/wrap case, covered by a reject test.) + $supertypeArgs = $interfaceSpec->concreteTypes; + $threaded = $this->hierarchy->resolveInheritedArgs($declaring, $supertypeArgs, $interfaceSpec->templateFqn); + if ($threaded === null || !$this->argsEqual($threaded, $supertypeArgs)) { + throw new RuntimeException($this->unschedulableMessage( + $interfaceSpec, + $methodName, + sprintf( + 'its declaring class "%s" does not pass its type parameters through to the interface ' + . 'unchanged, so the implementing specialization cannot be derived', + $declaring, + ), + )); + } + + $registry->recordInstantiation($declaring, $supertypeArgs); + } + + /** + * The nearest class in `$concreteFqn`'s ancestry (including itself) that declares `$methodName` + * as an erasable method WITH a body, or null if none does (interface-only / trait-only). + */ + private function declaringClassWithBody(string $concreteFqn, string $methodName, Registry $registry): ?string + { + $candidates = array_merge([$concreteFqn], $this->hierarchy->ancestorChain($concreteFqn)); + foreach ($candidates as $candidate) { + $definition = $registry->definition($candidate); + // @infection-ignore-all -- defensive skip of an ancestor that is not a generic class + // template (a non-generic base with no recorded definition, or an interface/trait). Either + // half short-circuits to the same `continue`, and the meaningful work — matching the method + // name and confirming erasability below — stays covered. + if ($definition === null || !$definition->templateAst instanceof Class_) { + continue; + } + foreach ($definition->templateAst->getMethods() as $method) { + // @infection-ignore-all -- the `stmts === null` half skips an abstract (bodyless) + // declaration of the same name; either half routes to the same `continue`, and the + // erasability confirmation below is the meaningful gate (covered by the success tests, + // where the matching method is found past a non-matching constructor). + if ($method->name->toString() !== $methodName || $method->stmts === null) { + continue; + } + $params = $method->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + if (!is_array($params)) { + continue; + } + /** @var list $params */ + if (EnclosingBoundErasure::isErasable($method, $params, $definition->typeParamNames())) { + return $candidate; + } + } + } + return null; + } + + /** + * The names of an interface's methods that lower to an erased (abstract) member. + * + * @return list + */ + private function erasableMethodNames(GenericDefinition $definition): array + { + $out = []; + foreach ($definition->templateAst->getMethods() as $method) { + $params = $method->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + if (!is_array($params)) { + continue; + } + /** @var list $params */ + if (EnclosingBoundErasure::isErasable($method, $params, $definition->typeParamNames())) { + $out[] = $method->name->toString(); + } + } + // @infection-ignore-all ArrayOneItem -- the collected names drive per-method scheduling + // (closeOne loops them), and for a covariant collection every element-consuming method declares + // its body on the same covariant base, so each name routes to the same declaring class and the + // same idempotent recordInstantiation. Truncating this list leaves the scheduled set unchanged. + return $out; + } + + /** + * @param list $a + * @param list $b + */ + private function argsEqual(array $a, array $b): bool + { + if (count($a) !== count($b)) { + return false; + } + foreach ($a as $i => $ref) { + if ($ref->canonical() !== $b[$i]->canonical()) { + return false; + } + } + return true; + } + + private function unschedulableMessage( + GenericInstantiation $interfaceSpec, + string $methodName, + string $reason, + ): string { + $args = implode(', ', array_map(static fn (TypeRef $r): string => $r->canonical(), $interfaceSpec->concreteTypes)); + return sprintf( + "[%s] A covariant upcast requires implementing the erased method \"%s\" from \"%s<%s>\", but %s.\n" + . ' Provide a concrete implementation reachable through a single covariant chain — e.g. give the ' + . 'declaring class no other parent, or move the method body onto the covariant base class.', + self::CODE_UNSCHEDULABLE_UPCAST, + $methodName, + $interfaceSpec->templateFqn, + $args, + $reason, + ); + } +} diff --git a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php index 994a7e5..195cf7a 100644 --- a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php +++ b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php @@ -821,6 +821,44 @@ public function testErasureIsVarianceSafeOnTheCovariantChainAtRuntime(): void } } + #[RunInSeparateProcess] + public function testCovariantInterfaceUpcastResolvesTheErasedMemberAtRuntime(): void + { + // The headline gate: a `ListColl` upcast to `Collection` calls the erased + // `contains` through the interface. The concrete supertype specialization that implements + // `Collection`'s abstract erased member (`AbstractColl`) is NEVER + // instantiated explicitly — the specialization closer must schedule it, or the emitted code + // fatals at class load. Proven by executing the output. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/enclosing_bound_interface_upcast/source', + 'iface-upcast', + ); + try { + $fixture->registerAutoload('App'); + require __DIR__ . '/../../fixture/compile/enclosing_bound_interface_upcast/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testMultiParamCovariantInterfaceUpcastResolvesAtRuntime(): void + { + // Multi-param: `HashMap` (K invariant, +V covariant) upcast to `MMap`. + // The closer must schedule `AbstractMap` — keep K=Id, raise V to Product — and the + // erased `containsValue` (mangled on V) must resolve through the covariant chain. Executed. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/enclosing_bound_interface_upcast_map/source', + 'iface-upcast-map', + ); + try { + $fixture->registerAutoload('App'); + require __DIR__ . '/../../fixture/compile/enclosing_bound_interface_upcast_map/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + #[RunInSeparateProcess] public function testVarianceEdgeDoesNotOverwriteASourceParentAtRuntime(): void { @@ -841,6 +879,286 @@ public function testVarianceEdgeDoesNotOverwriteASourceParentAtRuntime(): void } } + private const PRODUCT = " { + public function contains(E2 $value): bool; + } + PHP; + private const ABSTRACT_COLL = <<<'PHP' + implements Collection { + /** @var list */ + protected array $items; + public function __construct(E ...$items) { $this->items = $items; } + public function contains(E2 $value): bool { return \in_array($value, $this->items, true); } + } + PHP; + + public function testCovariantUpcastSchedulesTheConcreteSupertypeSpecialization(): void + { + // In-process (mutation-visible) proof of the closer's success path: a `ListColl` upcast + // to `Collection` schedules the implementing supertype `AbstractColl`, which + // is never instantiated explicitly. (The runtime fixture proves it then loads + runs.) + $result = $this->compileResult([ + 'Product.xphp' => self::PRODUCT, + 'Book.xphp' => self::BOOK, + 'Collection.xphp' => self::COLLECTION_IFACE, + 'AbstractColl.xphp' => self::ABSTRACT_COLL, + 'ListColl.xphp' => " extends AbstractColl {}\n", + 'Use.xphp' => <<<'PHP' + $c): bool { return $c->contains::(new Product()); } + $l = new ListColl::(new Book()); + $r = probe($l); + PHP, + ]); + + $abstractColl = self::specializationsOf($result, 'App\\AbstractColl'); + self::assertContains(['App\\Product'], $abstractColl, 'the closer must schedule AbstractColl'); + self::assertContains(['App\\Book'], $abstractColl, 'AbstractColl is the natural specialization'); + } + + public function testUpcastWhenTheConcreteClassDeclaresTheMethodItself(): void + { + // The erasable body is on the CONCRETE class itself (no abstract base, no other parent). The + // declaring-class search must include the concrete class itself, and schedule ListColl. + $result = $this->compileResult([ + 'Product.xphp' => self::PRODUCT, + 'Book.xphp' => self::BOOK, + 'Collection.xphp' => self::COLLECTION_IFACE, + 'ListColl.xphp' => <<<'PHP' + implements Collection { + /** @var list */ + protected array $items; + public function __construct(E ...$items) { $this->items = $items; } + public function contains(E2 $value): bool { return \in_array($value, $this->items, true); } + } + PHP, + 'Use.xphp' => <<<'PHP' + $c): bool { return $c->contains::(new Product()); } + $l = new ListColl::(new Book()); + $r = probe($l); + PHP, + ]); + + $listColl = self::specializationsOf($result, 'App\\ListColl'); + self::assertContains(['App\\Product'], $listColl, 'closer must schedule ListColl when the leaf itself declares the method'); + } + + public function testInvariantInterfaceParameterSchedulesNoUpcastImplementer(): void + { + // The interface parameter is INVARIANT, so `Holder` is NOT an instance of `Inv` + // even though Book <: Product. The closer must reach the strict-upcast check and decline to + // schedule (no `Holder` / no implementer for `Inv`). + $result = $this->compileResult([ + 'Product.xphp' => self::PRODUCT, + 'Book.xphp' => self::BOOK, + 'Inv.xphp' => <<<'PHP' + { + public function contains(E2 $value): bool; + } + PHP, + 'Holder.xphp' => <<<'PHP' + implements Inv { + /** @var list */ + protected array $items; + public function __construct(E ...$items) { $this->items = $items; } + public function contains(E2 $value): bool { return \in_array($value, $this->items, true); } + } + PHP, + 'Use.xphp' => <<<'PHP' + $x): bool { return $x->contains::(new Book()); } + function withProducts(Inv $x): bool { return $x->contains::(new Product()); } + $h = new Holder::(new Book()); + $r = withBooks($h); + PHP, + ]); + + // Holder is never instantiated and the invariant interface gives no upcast, so the + // closer schedules nothing for it. + self::assertSame([['App\\Book']], self::specializationsOf($result, 'App\\Holder'), 'invariant interface → no upcast → no extra Holder'); + } + + public function testInterfaceWithANonGenericMethodStillFindsTheErasableOne(): void + { + // The interface mixes a non-generic method (`size`) with the erasable `contains`. Collecting the + // erasable names must skip `size` and keep scanning to `contains` — the closer still schedules + // the implementer under a covariant upcast. + $result = $this->compileResult([ + 'Product.xphp' => self::PRODUCT, + 'Book.xphp' => self::BOOK, + 'Collection.xphp' => <<<'PHP' + { + public function size(): int; + public function contains(E2 $value): bool; + } + PHP, + 'AbstractColl.xphp' => <<<'PHP' + implements Collection { + /** @var list */ + protected array $items; + public function __construct(E ...$items) { $this->items = $items; } + public function size(): int { return \count($this->items); } + public function contains(E2 $value): bool { return \in_array($value, $this->items, true); } + } + PHP, + 'ListColl.xphp' => " extends AbstractColl {}\n", + 'Use.xphp' => <<<'PHP' + $c): bool { return $c->contains::(new Product()); } + $l = new ListColl::(new Book()); + $r = probe($l); + PHP, + ]); + + self::assertContains(['App\\Product'], self::specializationsOf($result, 'App\\AbstractColl'), 'the erasable method must be found past the non-generic one'); + } + + public function testDirectImplementationSchedulesNoExtraSpecialization(): void + { + // The concrete spec implements the interface spec it is used as DIRECTLY (no covariant upcast: + // ListColl used as Collection). The closer's argsEqual early-return must fire — no + // AbstractColl appears. + $result = $this->compileResult([ + 'Product.xphp' => self::PRODUCT, + 'Book.xphp' => self::BOOK, + 'Collection.xphp' => self::COLLECTION_IFACE, + 'AbstractColl.xphp' => self::ABSTRACT_COLL, + 'ListColl.xphp' => " extends AbstractColl {}\n", + 'Use.xphp' => <<<'PHP' + $c): bool { return $c->contains::(new Book()); } + $l = new ListColl::(new Book()); + $r = probe($l); + PHP, + ]); + + $abstractColl = self::specializationsOf($result, 'App\\AbstractColl'); + self::assertSame([['App\\Book']], $abstractColl, 'no upcast → only AbstractColl, nothing extra scheduled'); + } + + public function testDeclaringClassWithASourceParentMakesTheUpcastUnschedulable(): void + { + // The implementation lives on a class that itself extends another class, so the covariant edge + // that would inherit it can't be emitted (single inheritance). The closer must hard-fail rather + // than emit class-load-fataling code. + $this->expectException(RuntimeException::class); + // Assert the code, the erased method name, the interface, and the remedy all appear — pins the + // diagnostic's content, not just that it threw. + $this->expectExceptionMessageMatches( + '/xphp\.unschedulable_covariant_upcast.+erased method "contains".+App\\\\Collection.+' + . 'already extends another class.+PHP allows one parent.+' + . 'Provide a concrete implementation.+covariant base class/s', + ); + + $this->compileResult([ + 'Product.xphp' => self::PRODUCT, + 'Book.xphp' => self::BOOK, + 'Collection.xphp' => self::COLLECTION_IFACE, + 'Mid.xphp' => " {}\n", + 'ListColl.xphp' => <<<'PHP' + extends Mid implements Collection { + /** @var list */ + protected array $items; + public function __construct(E ...$items) { $this->items = $items; } + public function contains(E2 $value): bool { return \in_array($value, $this->items, true); } + } + PHP, + 'Use.xphp' => <<<'PHP' + $c): bool { return $c->contains::(new Product()); } + $l = new ListColl::(new Book()); + $r = probe($l); + PHP, + ]); + } + + public function testReorderedImplementsClauseMakesTheUpcastUnschedulable(): void + { + // The declaring class implements the interface with its parameters REORDERED + // (`class Holder<+A, B> implements Pair`), so the closer cannot derive the implementing + // specialization from the supertype's args (it would need to invert the mapping). It must + // hard-fail with the "does not pass its type parameters through" diagnostic, not schedule a + // wrong specialization. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches( + '/xphp\.unschedulable_covariant_upcast.+does not pass its type parameters through to the ' + . 'interface unchanged, so the implementing specialization cannot be derived/s', + ); + + $this->compileResult([ + 'Product.xphp' => self::PRODUCT, + 'Book.xphp' => self::BOOK, + 'Id.xphp' => " <<<'PHP' + { + public function contains(U $value): bool; + } + PHP, + 'Holder.xphp' => <<<'PHP' + implements Pair { + /** @var list */ + protected array $items; + public function __construct(A ...$items) { $this->items = $items; } + public function contains(U $value): bool { return \in_array($value, $this->items, true); } + } + PHP, + 'Use.xphp' => <<<'PHP' + $p): bool { return $p->contains::(new Product()); } + $h = new Holder::(new Book()); + $r = probe($h); + PHP, + ]); + } + public function testNullsafeForwardedSelfCallIsAlsoRewritten(): void { // A nullsafe forward (`$this?->contains::()`) is rewritten the same as the plain form. @@ -1010,6 +1328,55 @@ private function compile(array $files): string return $dist; } + /** + * Like compile(), but returns the CompileResult so a test can inspect which specializations the + * registry holds (used to assert the specialization closer's effect in-process, where mutation + * testing can attribute kills — the runtime fixtures run in separate processes). + * + * @param array $files + */ + private function compileResult(array $files): CompileResult + { + $src = $this->work . '/' . uniqid('src', true); + mkdir($src, 0o755, true); + foreach ($files as $name => $contents) { + file_put_contents($src . '/' . $name, $contents); + } + + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + $compiler = new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + $sources = (new NativeFileFinder())->find($src) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + + return $compiler->compile($sources, $src, $src . '/dist', $src . '/.xphp-cache'); + } + + /** + * The concrete type-argument lists recorded for a given template FQN, each as a list of canonical + * argument strings. Lets a test assert exactly which specializations of a class were scheduled. + * + * @return list> + */ + private static function specializationsOf(CompileResult $result, string $templateFqn): array + { + $out = []; + foreach ($result->registry->instantiations() as $instantiation) { + if ($instantiation->templateFqn === $templateFqn) { + $out[] = array_map(static fn ($ref): string => $ref->canonical(), $instantiation->concreteTypes); + } + } + return $out; + } + /** * Run the sources through `check` mode, which collects diagnostics instead of throwing. * diff --git a/test/fixture/compile/enclosing_bound_interface_upcast/source/AbstractColl.xphp b/test/fixture/compile/enclosing_bound_interface_upcast/source/AbstractColl.xphp new file mode 100644 index 0000000..d1b9ee2 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_interface_upcast/source/AbstractColl.xphp @@ -0,0 +1,21 @@ + implements Collection +{ + /** @var list */ + protected array $items; + + public function __construct(E ...$items) + { + $this->items = $items; + } + + public function contains(E2 $value): bool + { + return \in_array($value, $this->items, true); + } +} diff --git a/test/fixture/compile/enclosing_bound_interface_upcast/source/Book.xphp b/test/fixture/compile/enclosing_bound_interface_upcast/source/Book.xphp new file mode 100644 index 0000000..ad1d8fc --- /dev/null +++ b/test/fixture/compile/enclosing_bound_interface_upcast/source/Book.xphp @@ -0,0 +1,9 @@ + +{ + public function contains(E2 $value): bool; +} diff --git a/test/fixture/compile/enclosing_bound_interface_upcast/source/ListColl.xphp b/test/fixture/compile/enclosing_bound_interface_upcast/source/ListColl.xphp new file mode 100644 index 0000000..a011bae --- /dev/null +++ b/test/fixture/compile/enclosing_bound_interface_upcast/source/ListColl.xphp @@ -0,0 +1,9 @@ + extends AbstractColl +{ +} diff --git a/test/fixture/compile/enclosing_bound_interface_upcast/source/Product.xphp b/test/fixture/compile/enclosing_bound_interface_upcast/source/Product.xphp new file mode 100644 index 0000000..298054a --- /dev/null +++ b/test/fixture/compile/enclosing_bound_interface_upcast/source/Product.xphp @@ -0,0 +1,9 @@ +`, but is called with a +// `ListColl` (Book extends Product). The erased `contains_` declared (abstract) on +// Collection must be implemented for ListColl — through the covariant chain, via an +// AbstractColl specialization that is NEVER instantiated explicitly here. +function probe(Collection $c): bool +{ + return $c->contains::(new Product()); +} + +$books = new ListColl::(new Book()); +$found = probe($books); diff --git a/test/fixture/compile/enclosing_bound_interface_upcast/verify/runtime.php b/test/fixture/compile/enclosing_bound_interface_upcast/verify/runtime.php new file mode 100644 index 0000000..5c06878 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_interface_upcast/verify/runtime.php @@ -0,0 +1,26 @@ +` is upcast to `Collection` and an erased element-method + * (`contains`) is called through the interface. The interface specialization + * `Collection` declares the abstract `contains_`; for the program to load, the + * concrete supertype specialization that implements it (`AbstractColl`) must have been + * scheduled automatically — there is NO `new ListColl::()` anywhere in the source. + * + * That the program loads and `probe` returns proves the closer scheduled the implementer and the + * covariant chain inherited it. `probe` looks for a fresh Product in a list holding one Book, so the + * expected answer is false — the point is that the call resolves and runs at all. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertFalse($found, 'the upcast contains-call must resolve, run, and report the Product absent'); +echo "OK\n"; diff --git a/test/fixture/compile/enclosing_bound_interface_upcast_map/source/AbstractMap.xphp b/test/fixture/compile/enclosing_bound_interface_upcast_map/source/AbstractMap.xphp new file mode 100644 index 0000000..3de3b59 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_interface_upcast_map/source/AbstractMap.xphp @@ -0,0 +1,21 @@ + implements MMap +{ + /** @var list */ + protected array $values; + + public function __construct(V ...$values) + { + $this->values = $values; + } + + public function containsValue(U $value): bool + { + return \in_array($value, $this->values, true); + } +} diff --git a/test/fixture/compile/enclosing_bound_interface_upcast_map/source/Book.xphp b/test/fixture/compile/enclosing_bound_interface_upcast_map/source/Book.xphp new file mode 100644 index 0000000..ad1d8fc --- /dev/null +++ b/test/fixture/compile/enclosing_bound_interface_upcast_map/source/Book.xphp @@ -0,0 +1,9 @@ + extends AbstractMap +{ +} diff --git a/test/fixture/compile/enclosing_bound_interface_upcast_map/source/Id.xphp b/test/fixture/compile/enclosing_bound_interface_upcast_map/source/Id.xphp new file mode 100644 index 0000000..2d2c2e8 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_interface_upcast_map/source/Id.xphp @@ -0,0 +1,9 @@ + +{ + public function containsValue(U $value): bool; +} diff --git a/test/fixture/compile/enclosing_bound_interface_upcast_map/source/Product.xphp b/test/fixture/compile/enclosing_bound_interface_upcast_map/source/Product.xphp new file mode 100644 index 0000000..298054a --- /dev/null +++ b/test/fixture/compile/enclosing_bound_interface_upcast_map/source/Product.xphp @@ -0,0 +1,9 @@ + upcast to +// MMap must schedule the implementer AbstractMap — K kept (Id), V raised +// to the supertype arg (Product). No HashMap is instantiated explicitly. +function probe(MMap $m): bool +{ + return $m->containsValue::(new Product()); +} + +$map = new HashMap::(new Book()); +$found = probe($map); diff --git a/test/fixture/compile/enclosing_bound_interface_upcast_map/verify/runtime.php b/test/fixture/compile/enclosing_bound_interface_upcast_map/verify/runtime.php new file mode 100644 index 0000000..8746940 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_interface_upcast_map/verify/runtime.php @@ -0,0 +1,21 @@ +` (K invariant, +V covariant) is upcast to `MMap`. + * The erased `containsValue` mangles on V, so the closer must schedule `AbstractMap` + * — varying only the covariant V to the supertype arg while keeping the invariant K = Id — with NO + * explicit `HashMap` anywhere. Executing the output proves the threading is correct. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertFalse($found, 'the multi-param upcast containsValue-call must resolve and run'); +echo "OK\n"; From 5344daf362eb1d9623e11db36b2aeb29a40c761b Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 24 Jun 2026 21:29:17 +0000 Subject: [PATCH 096/114] docs(monomorphize): document the covariant-interface element-method upcast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record that an element-consuming method bounded by an enclosing type parameter (`contains`) declared on a covariant interface now works through an upcast (`ListColl` used as `Collection`): each interface specialization declares its own distinctly-named erased member, and the concrete implementation is scheduled and inherited down the covariant chain automatically. Documents the supported shape (implementation on a parent-less covariant base that passes its parameters through unchanged) and the new `xphp.unschedulable_covariant_upcast` compile error for the shapes that can't be carried down a single covariant chain (the implementing class has another parent, a trait-only body, or a reordered `implements` clause) — ground or fail, never emitted load-fataling code. Adds the diagnostic to errors.md, a worked example + boundary to type-bounds.md, and a consequence to ADR-0018. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ric-bounds-on-enclosing-type-parameters.md | 11 ++++++ docs/errors.md | 1 + docs/syntax/type-bounds.md | 35 +++++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md b/docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md index 35f4de4..0045495 100644 --- a/docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md +++ b/docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md @@ -91,6 +91,17 @@ the emitted code carries nothing). compiles and runs (the forward rewrites to the emitted member); it is the idiomatic way to call an element-consuming method from inside the class. The bound is still checked at the call site before erasure, so `Box::contains` is still rejected. +- **A covariant upcast to an interface schedules its implementer.** When the erasable method is declared + on a covariant *interface* (`Collection<+E>`) and a concrete `ListColl` is upcast to a supertype + specialization (`Collection`), that specialization declares a *distinct* abstract erased member + (`contains_`, separate from `contains_` — distinct names keep the covariant edge from + narrowing a parameter). The concrete implementation is carried down the covariant chain from the + declaring base specialized at the supertype's argument (`AbstractColl`), which the ordinary + fixed-point loop never discovers (an upcast is a usage relationship, not substitution). A specialization + closure step schedules it so the program loads and runs without an explicit instantiation of the + supertype. Where the implementation can't be carried down a single covariant chain — the declaring class + has another parent, a trait-only body, or a reordered `implements` clause — the upcast is a compile error + (`xphp.unschedulable_covariant_upcast`), never emitted load-fataling code. - **The residual `$this` self-calls still fail — loudly, never at runtime.** A *direct concrete* `$this->contains::()` self-call (its bound is checkable only on the abstract template) fails with `xphp.bound_unprovable`; a forward to a *non-erasable* method (parameter used nested, in the diff --git a/docs/errors.md b/docs/errors.md index a707282..400b4e8 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -50,6 +50,7 @@ The `json` and `github` formats tag each diagnostic with a stable code: | `xphp.bound_unprovable` | a method-generic bound that references an enclosing class type parameter (`contains`) can't be proven because the receiver's type argument isn't determinable here — a raw `Box` with no argument, a branch whose arms disagree, a static call, or a `$this` self-call. Ground the receiver (bind it to a typed local) or the build fails | | `xphp.undetermined_receiver` | a turbofish method call's receiver has no statically-known type (an untyped `foreach` variable, a local whose type is ambiguous after a branch), so the call can't be specialized — it would emit a call to a stripped method that fatals at runtime. Give the receiver a declared type | | `xphp.unspecializable_self_call` | a `$this`-rooted self-call forwards a type parameter to a **non-erasable** generic method (one whose parameter is used nested, in the return, or structurally). Forwarding to an *erasable* method — parameter used only as a direct input — compiles and runs; otherwise move the call to a typed-receiver context | +| `xphp.unschedulable_covariant_upcast` | a value is upcast to a covariant *interface* whose element-consuming method (`contains`) needs a concrete implementation inherited through the covariant chain, but it can't be carried there: the implementing class has another `extends` parent, the method body lives only in a trait, or the class reorders the interface's parameters. Put the implementation on a parent-less covariant base that passes its parameters through unchanged | | `xphp.parse_error` | the file isn't valid PHP after the generic strip pass | | `phpstan.*` | a PHPStan finding in the compiled output, mapped back to the template declaration (the code is `phpstan.` + PHPStan's own identifier, e.g. `phpstan.return.type`; a finding that carries no identifier falls back to the literal `phpstan.error`) — present only when the PHPStan pass runs | | `phpstan.unavailable` | (Warning) no phpstan binary was found, so the PHPStan pass was skipped | diff --git a/docs/syntax/type-bounds.md b/docs/syntax/type-bounds.md index dc57d43..62704a2 100644 --- a/docs/syntax/type-bounds.md +++ b/docs/syntax/type-bounds.md @@ -192,6 +192,41 @@ from within the class. A parameter used anywhere else (nested `Box`, a return, `new U`) keeps the per-call lowering and a forwarded self-call to it is still a compile error. +### Element-typed methods on a covariant interface + +The method may be declared on a covariant **interface** and called through a +covariant **upcast** — the shape a collections library uses: + +```php +interface Collection<+E> { + public function contains(E2 $value): bool; +} +abstract class AbstractColl<+E> implements Collection { + public function __construct(private E ...$items) {} + public function contains(E2 $value): bool { /* ... */ } +} +class ListColl<+E> extends AbstractColl {} + +function anyProduct(Collection $c): bool { + return $c->contains::(new Product()); +} +$books = new ListColl::(new Book()); // Book <: Product +anyProduct($books); // OK — upcast to Collection +``` + +Each interface specialization declares its own erased member +(`Collection` has `contains_`, `Collection` has +`contains_` — distinct, so the covariant edge never narrows a +parameter), and the concrete implementation is inherited through the covariant +chain. For that to work the element-consuming body must sit on a **parent-less +covariant base** that passes its type parameters straight to the interface — the +`AbstractColl<+E> implements Collection` shape above. If the implementing +class has another `extends` parent, supplies the body only through a trait, or +reorders the interface's parameters, the implementation can't be carried down a +single covariant chain, so the upcast is a compile error +(`xphp.unschedulable_covariant_upcast`) rather than a runtime fault — ground or +fail. + ## Caveats - > ⚠️ Bounds aren't checked across trait `use` boundaries — if a From 44576563ecb2bcfd4e822e1d011a10579b84389e Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 24 Jun 2026 21:29:27 +0000 Subject: [PATCH 097/114] docs(changelog): assemble the 0.3.0 section and backfill 0.2.1 Move the accumulated 0.3.0 work under a dedicated `[0.3.0] - Unreleased` heading (date set at tag time), add the missing entries (multi-root `xphp.json` manifest, generic-method inheritance, by-reference invariance, the covariant-interface element-method upcast), flag the `final`-on-a-variant-class rejection as a breaking Changed entry, and backfill a retroactive `[0.2.1]` section for the patch release that was never recorded. Fix the compare links accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 63 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b7f6dc..20cbe6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,21 @@ All notable changes to `xphp` are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.3.0] - Unreleased + +_In progress on this branch — content still accumulating; date set at tag time._ ### Added +- **Multi-root builds via an `xphp.json` manifest.** A project declares its source + roots, output directory, and hash length in an `xphp.json` at the project root; + `xphp compile` and `xphp check` auto-detect it (or take an explicit `--config`). + Roots are merged into a single source set — a template in one root can reference a + type in another — and include patterns accept a `**` globstar for recursive + discovery. A consuming build compiles only the `.xphp` sources whose own + `xphp.json` opts in (so `"vendor/**"` picks up the dependencies that ship a + manifest, and skips those that don't). See + [getting started](docs/getting-started.md). - **Element-typed methods on covariant collections.** A method-level type parameter bounded by an enclosing class type parameter — `class Box<+E> { public function contains(U $value): bool }` — has its bound **grounded** against the receiver's @@ -33,9 +44,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 idiomatic way to call an element-consuming method from inside the class). A `$this`-rooted forward to a *non-erasable* method (parameter used nested, in the return, or structurally), and a direct concrete `$this->contains::()`, remain compile errors - (`xphp.unspecializable_self_call` / `xphp.bound_unprovable`) — never a runtime fault. See - [type bounds](docs/syntax/type-bounds.md) and + (`xphp.unspecializable_self_call` / `xphp.bound_unprovable`) — never a runtime fault. **Covariant + interfaces:** the method may be declared on a covariant interface (`Collection<+E>`) and called + through an upcast (`ListColl` used as `Collection`) — the implementer specialization + is scheduled and inherited down the covariant chain automatically. When it can't be carried there + (the implementing class has another parent, a trait-only body, or a reordered `implements` clause) + the upcast is a compile error (`xphp.unschedulable_covariant_upcast`), never emitted load-fataling + code. See [type bounds](docs/syntax/type-bounds.md) and [ADR-0018](docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md). +- **Generic methods resolved through inheritance.** A generic method declared on a + base or abstract class is now callable by turbofish on a *subclass* receiver — + instance (`$child->m::()`), static (`Child::m::()`), and nullsafe + (`$child?->m::()`). It is specialized once on its declaring class and + inherited, so the call dispatches to the real member. An **unresolved** turbofish — + a generic method that exists on neither the receiver nor any ancestor — is now a + compile error (`xphp.unresolved_generic_call`) instead of an emitted call to a + stripped method that fatals at runtime. - **`xphp check`** — a validate-without-emitting CI gate. It runs every generic validation `xphp compile` does (bounds, variance, defaults, missing/duplicate generics, unsupported closures), but collects **all** problems in one run — @@ -73,6 +97,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and an externally-readable `public private(set)` property) stay strictly invariant. A covariant single-value getter over a `private T` field is also PHPStan-clean. See [variance](docs/syntax/variance.md). +- **By-reference parameters are an invariant variance position.** A `+T` / `-T` + type parameter used in a by-reference parameter (`&$x`) is now rejected: a + by-reference slot is both read and written through the caller's binding, so it is + invariant — the same rule already applied to a mutable property. See + [variance](docs/syntax/variance.md). + +### Changed + +- **BREAKING — a `final` variant class is now rejected.** A `final class Box<+T>` + previously compiled, with the generated specialization silently dropping `final` + so the `extends` subtype edge between specializations could land — which made + `ReflectionClass::isFinal()` disagree with the written source. A covariant / + contravariant class marked `final` is now a compile error (a `final` class can't + anchor the `extends` edge); omit `final` on a variant template. Non-variant + generic classes are unaffected. See [variance](docs/syntax/variance.md). ### Fixed @@ -95,6 +134,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 at runtime). It now fails `xphp compile` and is reported by `xphp check` as `xphp.undetermined_receiver`, with the fix: give the receiver a statically-known type. Ground or fail — the compiler never emits a call it knows will fatal. +- **A class bound that references a sibling type parameter is now checked** + correctly. A bound such as `class Pair` grounds `T` against the + supplied argument instead of treating `T` as a phantom class — which previously + rejected valid code with a misleading "does not extend/implement T". +- **A scalar bound is no longer flagged as an undeclared type.** A bound naming a + scalar (`int`, `string`, …) is no longer reported as `xphp.undeclared_type`. + +## [0.2.1] - 2026-06-17 + +### Fixed + +- The Composer autoloader is located when xphp is installed as a project + dependency, not only when run from its own checkout. +- Bare imported (`use`) names are fully-qualified in specialized classes, so a + generated specialization in a mirrored namespace resolves them correctly. ## [0.2.0] @@ -193,6 +247,7 @@ These are documented in full in the [caveats](docs/caveats.md): - Build-time hash-collision detection and a configurable `XPHP_HASH_LENGTH` (16–64). -[Unreleased]: https://github.com/xphp-lang/xphp/compare/v0.2.0...HEAD +[0.3.0]: https://github.com/xphp-lang/xphp/compare/v0.2.1...HEAD +[0.2.1]: https://github.com/xphp-lang/xphp/compare/v0.2.0...v0.2.1 [0.2.0]: https://github.com/xphp-lang/xphp/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/xphp-lang/xphp/releases/tag/v0.1.0 From df3bad9b2218abf0b7b51f2de376552316be7a99 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 25 Jun 2026 06:50:48 +0000 Subject: [PATCH 098/114] fix(monomorphize): report a turbofish-less method-generic call instead of skipping it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare (turbofish-less) call that resolved to a method-generic template without all-default type parameters was silently `return null`-ed — clean `check` and `compile`, then a runtime "Call to undefined method" fatal, since the class carries only the mangled specialization, never a plain method. The instance and static call-rewriters now fall through to `Registry::padArgsWithDefaults` for a bare call (`$args = []`) instead of short-circuiting: it pads an all-defaults generic as before, and reports/throws `xphp.missing_type_argument` (collected in `check`, thrown in `compile`) for one that can't infer its type argument. A method generic takes no inference, so a bare call to a non-all-default generic is an error, not a silent skip. Removes the now-unused `hasAllDefaults` helper (padArgsWithDefaults subsumes it). Non-generic and all-default bare calls are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/GenericMethodCompiler.php | 36 ++++-------- .../EnclosingParamBoundIntegrationTest.php | 57 +++++++++++++++++++ 2 files changed, 67 insertions(+), 26 deletions(-) diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index d194dd0..fbca1f3 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -1132,9 +1132,11 @@ private function rewriteStaticCall(StaticCall $node): ?Node // through padArgsWithDefaults too so partial-arg shapes are // filled in the same way class-level instantiations are. if (!is_array($args)) { - if (!self::hasAllDefaults($params)) { - return null; - } + // Bare call (no turbofish): fall through to padArgsWithDefaults, which pads an + // all-defaults generic and reports/throws `xphp.missing_type_argument` otherwise. A + // method generic can't infer its type argument from the call args, so a bare call to a + // non-all-default generic is an error — not a silent skip that emits a call to the + // stripped method and fatals at runtime. $args = []; } /** @var list $args — set as a list by XphpSourceParser::resolveAndAttach (or empty after the all-defaults branch above). */ @@ -1244,9 +1246,11 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): } /** @var list $params — set as a list by XphpSourceParser::resolveAndAttach. */ if (!is_array($args)) { - if (!self::hasAllDefaults($params)) { - return null; - } + // Bare call (no turbofish): fall through to padArgsWithDefaults, which pads an + // all-defaults generic and reports/throws `xphp.missing_type_argument` otherwise. A + // method generic can't infer its type argument from the call args, so a bare call to a + // non-all-default generic is an error — not a silent skip that emits a call to the + // stripped method and fatals at runtime. $args = []; } /** @var list $args — set as a list by XphpSourceParser::resolveAndAttach (or empty after the all-defaults branch above). */ @@ -2263,26 +2267,6 @@ private static function allConcrete(array $args): bool return true; } - /** - * True iff every TypeParam in the template carries a default. - * Bare calls (no `::<...>`) can specialize only against all-defaults - * templates -- otherwise there's no way to derive the type-args. - * - * @param list $params - */ - private static function hasAllDefaults(array $params): bool - { - if ($params === []) { - return false; - } - foreach ($params as $param) { - if ($param->default === null) { - return false; - } - } - return true; - } - /** * @param list $args */ diff --git a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php index 195cf7a..fbe1352 100644 --- a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php +++ b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php @@ -1159,6 +1159,63 @@ function probe(Pair $p): bool { return $p->contains::(new ]); } + public function testBareInstanceMethodGenericCallFailsCompile(): void + { + // A turbofish-less call to a method generic with no all-default params can't infer its type + // argument — it must fail compile, not silently emit a call to the stripped `pick_T_<…>`. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/pick/'); + + $this->compile([ + 'Box.xphp' => "(T \$x): T { return \$x; } }\n", + 'Use.xphp' => "pick('b');\n", + ]); + } + + public function testBareInstanceMethodGenericCallIsCollectedInCheck(): void + { + // The same bare call in `check` mode is collected (not thrown), so a whole-program check reports + // it instead of a runtime fatal — the gap the ticket is about. + $collector = $this->check([ + 'Box.xphp' => "(T \$x): T { return \$x; } }\n", + 'Use.xphp' => "pick('b');\n", + ]); + $codes = array_map(static fn (Diagnostic $d): string => $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testBareCallToAnAllDefaultMethodGenericStillResolves(): void + { + // A bare call to a method generic whose type parameter is fully defaulted pads to the default and + // compiles — unchanged behavior, no false positive. + $dist = $this->compile([ + 'Box.xphp' => "(): string { return 'x'; } }\n", + 'Use.xphp' => "make();\n", + ]); + self::assertStringContainsString('make_', self::read($dist, 'Use.php')); + } + + public function testBareCallToANonGenericMethodIsUnaffected(): void + { + // A plain call to a non-generic method must not be flagged (it isn't a method-generic template). + $collector = $this->check([ + 'Box.xphp' => " "plain('x');\n", + ]); + self::assertSame([], $collector->all(), 'a non-generic bare call must not be flagged'); + } + + public function testBareStaticMethodGenericCallIsReported(): void + { + // The static path (`Box::pick('b')`) has the identical silent-skip branch — also reported. + $collector = $this->check([ + 'Box.xphp' => "(T \$x): T { return \$x; } }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + public function testNullsafeForwardedSelfCallIsAlsoRewritten(): void { // A nullsafe forward (`$this?->contains::()`) is rewritten the same as the plain form. From 24b504170710551fe4d0d1d4e95747fef7e5898c Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 25 Jun 2026 06:55:53 +0000 Subject: [PATCH 099/114] fix(monomorphize): report a turbofish-less generic free-function call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare (turbofish-less) call to a generic free function was skipped by a separate early return (it fires for non-generic calls too, before any template resolution), so a forgotten turbofish on `pick(...)` emitted `pick('b')` verbatim against a class/namespace that holds only the mangled `pick_T_<…>` — a runtime "undefined function" fatal that neither check nor compile caught. rewriteFuncCall now resolves a bare call's name to a registered generic-function FQN (mirroring PHP resolution: fully-qualified / `use`-aliased direct, unqualified tries the current namespace then the global scope) and reports `xphp.missing_type_argument` via padArgsWithDefaults. functionTemplates holds only generic functions, so a non-generic bare call resolves to null and is left untouched — no false positives. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/GenericMethodCompiler.php | 52 +++++++++++++++++++ .../EnclosingParamBoundIntegrationTest.php | 31 +++++++++++ 2 files changed, 83 insertions(+) diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index fbca1f3..9a750b4 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -1981,6 +1981,28 @@ private function rewriteFuncCall(FuncCall $node): ?Node { $args = $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); if (!is_array($args)) { + // Bare (turbofish-less) call. A named generic function takes no inference and has no + // bare/empty-turbofish form, so a bare call to one is a missing-type-arguments error, + // not a silent skip that emits a call to the stripped `f_T_<…>`. Resolve whether the + // callee is a registered generic function (functionTemplates holds ONLY generics, so a + // non-generic call resolves to null and is left untouched — no false positives). + if ($node->name instanceof Name) { + $fqn = $this->resolveGenericFunctionFqn($node->name); + if ($fqn !== null) { + $params = $this->functionTemplates[$fqn] + ->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + if (is_array($params)) { + /** @var list $params */ + Registry::padArgsWithDefaults( + $params, + [], + $fqn, + $this->diagnostics, + new SourceLocation($this->currentFile, $node->getStartLine()), + ); + } + } + } return null; } /** @var list $args — set as a list by XphpSourceParser::resolveAndAttach. */ @@ -2254,6 +2276,36 @@ private function resolveClassName(Name $name): string : $raw; } + /** + * Resolve a (turbofish-less) function-call name to the FQN of a registered GENERIC function + * template, or null if it isn't one. `functionTemplates` holds only generic functions, so a + * non-generic call (e.g. `strlen`) resolves to null and is left untouched. Mirrors PHP's + * function name resolution: fully-qualified and `use`-aliased names resolve directly; an + * unqualified name tries the current namespace first, then falls back to the global scope. + */ + private function resolveGenericFunctionFqn(Name $name): ?string + { + if ($name instanceof FullyQualified || str_starts_with($name->toString(), '\\')) { + $fqn = ltrim($name->toString(), '\\'); + return isset($this->functionTemplates[$fqn]) ? $fqn : null; + } + $raw = $name->toString(); + $first = self::firstSegment($raw); + if (isset($this->useMap[$first])) { + $fqn = $this->useMap[$first] . substr($raw, strlen($first)); + return isset($this->functionTemplates[$fqn]) ? $fqn : null; + } + if ($this->currentNamespace !== '') { + $namespaced = $this->currentNamespace . '\\' . $raw; + if (isset($this->functionTemplates[$namespaced])) { + return $namespaced; + } + } + // Global-scope fallback (PHP resolves an unqualified function to global when the + // namespaced one doesn't exist). + return isset($this->functionTemplates[$raw]) ? $raw : null; + } + /** * @param list $args */ diff --git a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php index fbe1352..97e7700 100644 --- a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php +++ b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php @@ -1216,6 +1216,37 @@ public function testBareStaticMethodGenericCallIsReported(): void self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); } + public function testBareFreeFunctionGenericCallFailsCompile(): void + { + // The free-function path skipped bare calls via a different early return; a bare call to a + // generic function must also fail rather than emit a call to the stripped `pick_T_<…>`. + $this->expectException(RuntimeException::class); + $this->compile([ + 'fns.xphp' => "(T \$x): T { return \$x; }\n", + 'Use.xphp' => "check([ + 'fns.xphp' => "(T \$x): T { return \$x; }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testBareNonGenericFreeFunctionCallIsUnaffected(): void + { + // A bare call to a non-generic free function (not in functionTemplates) must not be flagged. + $collector = $this->check([ + 'fns.xphp' => " "all(), 'a non-generic free-function bare call must not be flagged'); + } + public function testNullsafeForwardedSelfCallIsAlsoRewritten(): void { // A nullsafe forward (`$this?->contains::()`) is rewritten the same as the plain form. From ad655c63a0f7ee81c41465099da805e1aac77cfd Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 25 Jun 2026 07:07:01 +0000 Subject: [PATCH 100/114] fix(monomorphize): report a turbofish-less generic closure call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A generic closure is tracked at assignment, but a bare `$f('x')` (no turbofish) was doubly skipped: the func-call rewriter bailed before resolving it, and — worse — the process() fast-path early-return only kept traversal alive for a variable *turbofish* call site, so a file whose only generic-closure use was a bare call wasn't traversed at all (clean check + compile, runtime fatal). The bare-call branch now also resolves a `$f(...)` against the tracked currentScopeClosureTemplates and reports `xphp.missing_type_argument` for a non-all-default generic closure (shared with the free-function path via resolveBareGenericCall). The anonymous-generic pre-scan now also fires on a generic closure/arrow template assignment, so a bare call to it is traversed and diagnosed. A non-generic variable call resolves to nothing and is left untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/GenericMethodCompiler.php | 87 +++++++++++++------ .../EnclosingParamBoundIntegrationTest.php | 31 +++++++ 2 files changed, 92 insertions(+), 26 deletions(-) diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 9a750b4..507cd9b 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -1981,27 +1981,23 @@ private function rewriteFuncCall(FuncCall $node): ?Node { $args = $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); if (!is_array($args)) { - // Bare (turbofish-less) call. A named generic function takes no inference and has no - // bare/empty-turbofish form, so a bare call to one is a missing-type-arguments error, - // not a silent skip that emits a call to the stripped `f_T_<…>`. Resolve whether the - // callee is a registered generic function (functionTemplates holds ONLY generics, so a - // non-generic call resolves to null and is left untouched — no false positives). - if ($node->name instanceof Name) { - $fqn = $this->resolveGenericFunctionFqn($node->name); - if ($fqn !== null) { - $params = $this->functionTemplates[$fqn] - ->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); - if (is_array($params)) { - /** @var list $params */ - Registry::padArgsWithDefaults( - $params, - [], - $fqn, - $this->diagnostics, - new SourceLocation($this->currentFile, $node->getStartLine()), - ); - } - } + // Bare (turbofish-less) call. A generic free function or a tracked generic closure + // takes no inference, so a bare call to a non-all-default one is a + // missing-type-arguments error, not a silent skip that emits a call to the stripped + // `f_T_<…>`. Both lookups (functionTemplates / the closure bag) hold ONLY generics, so + // a non-generic bare call resolves to nothing and is left untouched — no false + // positives. (An all-default generic pads silently and is left as-is; the user can + // still call it with an explicit/empty turbofish.) + $bare = $this->resolveBareGenericCall($node); + if ($bare !== null) { + [$params, $label] = $bare; + Registry::padArgsWithDefaults( + $params, + [], + $label, + $this->diagnostics, + new SourceLocation($this->currentFile, $node->getStartLine()), + ); } return null; } @@ -2276,6 +2272,38 @@ private function resolveClassName(Name $name): string : $raw; } + /** + * For a turbofish-less call, return the called generic template's `[params, label]` — a + * registered generic free function (by resolved FQN) or a tracked generic closure (by + * variable) — or null when the callee isn't a generic template (non-generic calls, unknown + * variables). The label is the diagnostic's template name. + * + * @return array{0: list, 1: string}|null + */ + private function resolveBareGenericCall(FuncCall $node): ?array + { + if ($node->name instanceof Name) { + $fqn = $this->resolveGenericFunctionFqn($node->name); + if ($fqn === null) { + return null; + } + $params = $this->functionTemplates[$fqn] + ->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + /** @var list|null $params */ + return is_array($params) ? [$params, $fqn] : null; + } + if ($node->name instanceof Variable && is_string($node->name->name)) { + $template = $this->currentScopeClosureTemplates[$node->name->name] ?? null; + if ($template === null) { + return null; + } + $params = $template->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + /** @var list|null $params */ + return is_array($params) ? [$params, '$' . $node->name->name] : null; + } + return null; + } + /** * Resolve a (turbofish-less) function-call name to the FQN of a registered GENERIC function * template, or null if it isn't one. `functionTemplates` holds only generic functions, so a @@ -2500,11 +2528,12 @@ public function __construct(private bool &$found) { } /** - * @infection-ignore-all -- pure perf optimization. Mutating the - * early-return or the instanceof guard just disables the fast-path - * exit; the subsequent rewriteCallSites pass is idempotent for - * files with no matching call sites, so observable behavior is - * identical with or without this pre-scan firing. + * @infection-ignore-all -- this pre-scan keeps the rewrite pass alive for files that have no + * NAMED generic templates but do use anonymous generics. It fires on a variable turbofish + * call (`$f::<…>()`) AND on a generic closure/arrow TEMPLATE assignment — the latter so a + * BARE call to that closure (`$f('x')`, no turbofish) is still traversed and diagnosed rather + * than silently skipped. (Inner anonymous visitor: Infection's blind spot — the closure-call + * diagnosis is covered behaviorally by the bare-closure-call accept/reject tests.) */ public function enterNode(Node $node): null { @@ -2517,6 +2546,12 @@ public function enterNode(Node $node): null ) { $this->found = true; } + if ($node instanceof Assign + && ($node->expr instanceof Closure || $node->expr instanceof ArrowFunction) + && is_array($node->expr->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS)) + ) { + $this->found = true; + } return null; } }); diff --git a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php index 97e7700..9b5a71b 100644 --- a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php +++ b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php @@ -1247,6 +1247,37 @@ public function testBareNonGenericFreeFunctionCallIsUnaffected(): void self::assertSame([], $collector->all(), 'a non-generic free-function bare call must not be flagged'); } + public function testBareGenericClosureCallIsCollectedInCheck(): void + { + // A generic closure is tracked at assignment; a bare `$f('b')` (no turbofish) was skipped via + // the func-call early return and never reached the variable-turbofish path. It must be reported. + $collector = $this->check([ + 'Use.xphp' => <<<'PHP' + (T $x): T { return $x; }; + $bad = $f('b'); + PHP, + ]); + $codes = array_map(static fn (Diagnostic $d): string => $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testBareGenericClosureCallFailsCompile(): void + { + $this->expectException(RuntimeException::class); + $this->compile([ + 'Use.xphp' => <<<'PHP' + (T $x): T { return $x; }; + $bad = $f('b'); + PHP, + ]); + } + public function testNullsafeForwardedSelfCallIsAlsoRewritten(): void { // A nullsafe forward (`$this?->contains::()`) is rewritten the same as the plain form. From b2b30f7e22e77c331399eba5fb7f1ddbea62408d Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 25 Jun 2026 07:08:52 +0000 Subject: [PATCH 101/114] docs(monomorphize): document that a turbofish-less generic call is an error A method/function/closure type argument is not inferred from the call arguments, so a turbofish-less call to a non-all-default generic is `xphp.missing_type_argument` (caught by check/compile) rather than a silent runtime fatal. Notes the broadened scope on the diagnostic in errors.md and adds the rule to turbofish.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/errors.md | 2 +- docs/syntax/turbofish.md | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/errors.md b/docs/errors.md index 400b4e8..b70c1e0 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -37,7 +37,7 @@ The `json` and `github` formats tag each diagnostic with a stable code: |------|---------| | `xphp.bound_violation` | a concrete type argument doesn't satisfy its parameter's bound | | `xphp.default_bound_violation` | a parameter's default doesn't satisfy its own bound | -| `xphp.missing_type_argument` | a required type argument was omitted and has no default | +| `xphp.missing_type_argument` | a required type argument was omitted and has no default — including a **turbofish-less call** to a generic method, function, or closure (`$x->pick('a')` instead of `$x->pick::('a')`): a method generic takes no inference, so the type argument must be supplied explicitly | | `xphp.too_many_type_arguments` | more type arguments were supplied than the template declares (e.g. `Box::` for a one-parameter `Box`) | | `xphp.variance_position` | a `+T`/`-T` parameter appears in a position its variance forbids | | `xphp.inner_variance` | variance is violated through another generic's slot (composition) | diff --git a/docs/syntax/turbofish.md b/docs/syntax/turbofish.md index 87491ce..b721244 100644 --- a/docs/syntax/turbofish.md +++ b/docs/syntax/turbofish.md @@ -74,6 +74,13 @@ $id('T_', 42); template is all-defaulted. - Bare `new Foo;` (no `(` or `::<>`) also works for all-defaulted class templates — see [defaults](defaults.md). +- **The type argument is not inferred from the call arguments.** A + turbofish-less call to a generic method, function, or closure that + has no all-default type parameters (`$x->pick('a')` instead of + `$x->pick::('a')`) is a compile error + (`xphp.missing_type_argument`), not a silent skip — `xphp check` + catches a forgotten turbofish at build time rather than letting it + fatal at runtime. ## Receiver-type analysis (instance methods) From 9cce945382bc8325739dd1ee20de164270fdbd2c Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 25 Jun 2026 07:27:30 +0000 Subject: [PATCH 102/114] fix(monomorphize): close three edge cases in turbofish-less call detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From code review of the bare-call diagnostics: - Scope the closure-template tracking. `currentScopeClosureTemplates` was never reset on function/method/closure boundaries, so a generic closure assigned to `$f` in one method leaked into a sibling scope where `$f` is an unrelated `callable` parameter — a bare `$f(...)` there was wrongly reported. It's now snapshotted/reset/restored alongside the other per-scope maps (also fixes a latent leak in the turbofish path). - Skip first-class callables. `pick(...)` / `$obj->m(...)` creates a Closure rather than invoking, so it must not be reported as a missing-turbofish call. - Report a bare generic free-function/closure call even when all type parameters are defaulted. A named generic function/closure has no bare or empty-turbofish form, so an all-default bare call previously padded silently and still fataled at runtime; it now reports `xphp.missing_type_argument` (a generic method whose params are all defaulted may still be called bare — that path is unchanged). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/syntax/turbofish.md | 9 ++- .../Monomorphize/GenericMethodCompiler.php | 78 +++++++++++++++---- .../EnclosingParamBoundIntegrationTest.php | 42 ++++++++++ 3 files changed, 109 insertions(+), 20 deletions(-) diff --git a/docs/syntax/turbofish.md b/docs/syntax/turbofish.md index b721244..b7b3c46 100644 --- a/docs/syntax/turbofish.md +++ b/docs/syntax/turbofish.md @@ -75,12 +75,15 @@ $id('T_', 42); - Bare `new Foo;` (no `(` or `::<>`) also works for all-defaulted class templates — see [defaults](defaults.md). - **The type argument is not inferred from the call arguments.** A - turbofish-less call to a generic method, function, or closure that - has no all-default type parameters (`$x->pick('a')` instead of + turbofish-less call (`$x->pick('a')` instead of `$x->pick::('a')`) is a compile error (`xphp.missing_type_argument`), not a silent skip — `xphp check` catches a forgotten turbofish at build time rather than letting it - fatal at runtime. + fatal at runtime. A generic **method** whose type parameters are all + defaulted may still be called bare; a named generic **function** or + **closure** has no bare or empty-turbofish form, so it always needs + an explicit turbofish. (A first-class callable `pick(...)` creates a + closure rather than calling, and is left alone.) ## Receiver-type analysis (instance methods) diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 507cd9b..fafc74c 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -592,12 +592,19 @@ public function enterNode(Node $node): null 'paramArgs' => $parentParamArgs, 'localArgs' => $parentLocalArgs, 'branches' => $this->branchSnapshots, + // Closure-template tracking is per-scope too: a generic closure assigned to `$f` + // in one function must NOT leak into a sibling scope where `$f` is an unrelated + // callable, or a bare `$f(...)` there would be misreported. + 'closureTemplates' => $this->currentScopeClosureTemplates, + 'closureContexts' => $this->currentScopeClosureContexts, ]; $this->currentScopeParamTypes = []; $this->currentScopeLocalTypes = []; $this->currentScopeParamTypeArgs = []; $this->currentScopeLocalTypeArgs = []; $this->branchSnapshots = []; + $this->currentScopeClosureTemplates = []; + $this->currentScopeClosureContexts = []; // For closures: `use ($x)` explicitly imports outer variables. // Copy each imported name's type from the parent scope so the @@ -814,6 +821,8 @@ public function leaveNode(Node $node): ?Node $this->currentScopeParamTypeArgs = $snapshot['paramArgs']; $this->currentScopeLocalTypeArgs = $snapshot['localArgs']; $this->branchSnapshots = $snapshot['branches']; + $this->currentScopeClosureTemplates = $snapshot['closureTemplates']; + $this->currentScopeClosureContexts = $snapshot['closureContexts']; } else { // Defensive: matched enter/leave count is invariant of the // NodeTraverser; the else-branch is only reachable if the AST @@ -824,6 +833,8 @@ public function leaveNode(Node $node): ?Node $this->currentScopeParamTypeArgs = []; $this->currentScopeLocalTypeArgs = []; $this->branchSnapshots = []; + $this->currentScopeClosureTemplates = []; + $this->currentScopeClosureContexts = []; } } // Branching parents: pop the frame, restore the pre-branch local @@ -1132,6 +1143,11 @@ private function rewriteStaticCall(StaticCall $node): ?Node // through padArgsWithDefaults too so partial-arg shapes are // filled in the same way class-level instantiations are. if (!is_array($args)) { + // A first-class-callable (`$obj->m(...)` / `Box::m(...)`) creates a Closure rather + // than invoking, so leave it alone. + if ($node->isFirstClassCallable()) { + return null; + } // Bare call (no turbofish): fall through to padArgsWithDefaults, which pads an // all-defaults generic and reports/throws `xphp.missing_type_argument` otherwise. A // method generic can't infer its type argument from the call args, so a bare call to a @@ -1246,6 +1262,11 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): } /** @var list $params — set as a list by XphpSourceParser::resolveAndAttach. */ if (!is_array($args)) { + // A first-class-callable (`$obj->m(...)` / `Box::m(...)`) creates a Closure rather + // than invoking, so leave it alone. + if ($node->isFirstClassCallable()) { + return null; + } // Bare call (no turbofish): fall through to padArgsWithDefaults, which pads an // all-defaults generic and reports/throws `xphp.missing_type_argument` otherwise. A // method generic can't infer its type argument from the call args, so a bare call to a @@ -1532,6 +1553,31 @@ private function reportUnspecializableSelfCall(string $methodName, SourceLocatio throw new RuntimeException($message); } + /** + * A turbofish-less call to a generic free function or closure (`$label` is its FQN or + * `$var`). Unlike a method, a named generic function / closure has no bare or empty-turbofish + * form, so any bare call is missing its type arguments regardless of defaults. Collect-or-throw. + */ + private function reportMissingTurbofishArguments(string $label, SourceLocation $location): void + { + $message = sprintf( + 'Generic call `%s(...)` is missing its type arguments: a generic function or closure ' + . 'takes no inference, so it must be called with an explicit turbofish `%s::<...>(...)`.', + $label, + $label, + ); + if ($this->diagnostics !== null) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + Registry::CODE_MISSING_TYPE_ARGUMENT, + $message, + $location, + )); + return; + } + throw new RuntimeException($message); + } + /** * Whether the called method is an erasable `` method on its declaring class — i.e. * the Specializer will lower it (and rewrite a forwarded self-call to it) per instantiation. @@ -1981,23 +2027,21 @@ private function rewriteFuncCall(FuncCall $node): ?Node { $args = $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); if (!is_array($args)) { - // Bare (turbofish-less) call. A generic free function or a tracked generic closure - // takes no inference, so a bare call to a non-all-default one is a - // missing-type-arguments error, not a silent skip that emits a call to the stripped - // `f_T_<…>`. Both lookups (functionTemplates / the closure bag) hold ONLY generics, so - // a non-generic bare call resolves to nothing and is left untouched — no false - // positives. (An all-default generic pads silently and is left as-is; the user can - // still call it with an explicit/empty turbofish.) - $bare = $this->resolveBareGenericCall($node); - if ($bare !== null) { - [$params, $label] = $bare; - Registry::padArgsWithDefaults( - $params, - [], - $label, - $this->diagnostics, - new SourceLocation($this->currentFile, $node->getStartLine()), - ); + // Bare (turbofish-less) call. A first-class-callable (`f(...)` / `$f(...)`) creates a + // Closure rather than invoking, so leave it alone. Otherwise a generic free function + // or tracked generic closure has no bare or empty-turbofish form (a named generic + // takes no inference and the dispatcher needs concrete args), so ANY bare call to one + // is a missing-type-arguments error — not a silent skip that emits a call to the + // stripped `f_T_<…>`. Both lookups hold ONLY generics, so a non-generic bare call + // resolves to nothing and is left untouched — no false positives. + if (!$node->isFirstClassCallable()) { + $bare = $this->resolveBareGenericCall($node); + if ($bare !== null) { + $this->reportMissingTurbofishArguments( + $bare[1], + new SourceLocation($this->currentFile, $node->getStartLine()), + ); + } } return null; } diff --git a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php index 9b5a71b..f2304dd 100644 --- a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php +++ b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php @@ -1278,6 +1278,48 @@ public function testBareGenericClosureCallFailsCompile(): void ]); } + public function testFirstClassCallableOfAGenericFunctionIsNotFlagged(): void + { + // `pick(...)` (first-class-callable) creates a Closure, it doesn't invoke — so it must NOT be + // reported as a missing-turbofish call. + $collector = $this->check([ + 'fns.xphp' => "(T \$x): T { return \$x; }\n", + 'Use.xphp' => "all(), 'a first-class-callable of a generic function must not be flagged'); + } + + public function testBareAllDefaultFreeFunctionCallIsReported(): void + { + // A named generic function has no bare/empty-turbofish form even when all params are defaulted, + // so a bare call must be reported rather than silently padded and left to fatal at runtime. + $collector = $this->check([ + 'fns.xphp' => "(): string { return 'x'; }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testGenericClosureDoesNotLeakAcrossScopes(): void + { + // A generic closure assigned in one method must not leak its template into a sibling method + // where the same variable name is an unrelated `callable` parameter — a bare `$f('x')` there is + // a legitimate call, not a forgotten turbofish. + $collector = $this->check([ + 'C.xphp' => <<<'PHP' + (T $x): T { return $x; }; } + public function b(callable $f): mixed { return $f('x'); } + } + PHP, + ]); + self::assertSame([], $collector->all(), 'a generic closure must not leak across scopes'); + } + public function testNullsafeForwardedSelfCallIsAlsoRewritten(): void { // A nullsafe forward (`$this?->contains::()`) is rewritten the same as the plain form. From 37a743b975e5fe1a4a5f66b3e0639a6391218095 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 25 Jun 2026 07:33:11 +0000 Subject: [PATCH 103/114] docs(changelog): record the turbofish-less generic-call diagnostic A bare (turbofish-less) call to a generic method/function/closure now fails compile and is collected by check as xphp.missing_type_argument, instead of silently emitting a call to the stripped mangled member and fataling at runtime. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20cbe6f..952c9f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -140,6 +140,14 @@ _In progress on this branch — content still accumulating; date set at tag time rejected valid code with a misleading "does not extend/implement T". - **A scalar bound is no longer flagged as an undeclared type.** A bound naming a scalar (`int`, `string`, …) is no longer reported as `xphp.undeclared_type`. +- **A turbofish-less call to a generic method, function, or closure is now rejected** + instead of silently skipped. A method generic takes no inference, so a forgotten + turbofish (`$x->pick('a')` instead of `$x->pick::('a')`) previously emitted a + call to the stripped mangled member and fataled at runtime — clean `check` and + `compile`. It now fails `xphp compile` and is collected by `xphp check` as + `xphp.missing_type_argument`, across instance, static, free-function, and closure + calls. A generic whose type parameters are all defaulted still resolves; a + first-class callable (`pick(...)`) and a non-generic call are unaffected. ## [0.2.1] - 2026-06-17 From 946afb850b409a382427aae260651c25a4348b78 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 25 Jun 2026 21:29:18 +0000 Subject: [PATCH 104/114] fix(monomorphize): include closure-template maps in the scope-snapshot type The per-scope closure-template and closure-context maps are pushed and restored across nested function/closure boundaries, but the snapshot stack's `@var` shape omitted both keys, so static analysis read the restore as accessing non-existent offsets. Add the two keys to the declared shape; no behavioral change. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/GenericMethodCompiler.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index fafc74c..7237d68 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -484,9 +484,11 @@ private function rewriteCallSites( * Function_/ClassMethod/Closure/ArrowFunction boundaries. On enter we push the * outgoing `(params, locals, branches)` triple; on leave we pop and restore. * Branch snapshots are nested per-scope so that branches inside a closure - * don't leak to branches in the enclosing function. + * don't leak to branches in the enclosing function. The closure-template maps + * are snapshotted too so a generic closure assigned in one scope doesn't leak + * into a sibling scope where the same variable names an unrelated callable. * - * @var list, locals: array, paramArgs: array>, localArgs: array>, branches: list, localArgsSnapshot: array>, assigned: array, perBranchTypes: list>, perBranchArgs: list>>, armIndex: int}>}> + * @var list, locals: array, paramArgs: array>, localArgs: array>, branches: list, localArgsSnapshot: array>, assigned: array, perBranchTypes: list>, perBranchArgs: list>>, armIndex: int}>, closureTemplates: array, closureContexts: array}> */ private array $scopeSnapshots = []; /** From 555af5deff6961e159d9a0070a930f97466c9465 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 25 Jun 2026 21:30:09 +0000 Subject: [PATCH 105/114] feat(monomorphize): emit an erased upcast member directly when inheritance can't carry it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A covariant upcast `Concrete` used as `Interface` must supply the interface's erased element method specialized at the supertype arg. The covariant-edge path inherits that member only when its body lives on a parent-less class implementing that exact interface. The common collections shape — a list and a set sharing an abstract base, with a typed method declared on a sub-interface — violates that, and the upcast previously hard-failed even though a sound member could be emitted. When inheritance can't carry the member, emit it directly onto the upcast-source class with a split substitution: the method's bound type parameter widens to the supertype argument, while the body's enclosing class parameter is grounded to the upcast-source's own concrete element. This is sound because the upcast source's element type is a subtype of the supertype argument, so reading the instance's own backing state through the widened parameter is type-safe — the same guarantee the inheritance path already relies on. Distinct supertype arguments produce distinct mangled names, so a class upcast to several supertypes gets one member each with no redeclaration. A method whose parameters are bounded by different enclosing parameters isn't the uniformly-bounded shape direct emission can derive a single member for; rather than silently leave the abstract member unimplemented (a load fatal), it fails loudly with the upcast diagnostic. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/Compiler.php | 4 +- .../Monomorphize/SpecializationCloser.php | 209 ++++++++++--- .../EnclosingParamBoundIntegrationTest.php | 287 ++++++++++++++++-- .../source/AbstractColl.xphp | 21 ++ .../source/Book.xphp | 9 + .../source/Collection.xphp | 10 + .../source/ListColl.xphp | 18 ++ .../source/OrderedCollection.xphp | 10 + .../source/Product.xphp | 9 + .../source/Use.xphp | 22 ++ .../verify/runtime.php | 23 ++ .../source/AbstractColl.xphp | 21 ++ .../source/Book.xphp | 9 + .../source/Collection.xphp | 10 + .../source/ListColl.xphp | 16 + .../source/OrderedCollection.xphp | 10 + .../source/Product.xphp | 9 + .../source/Use.xphp | 13 + .../verify/runtime.php | 22 ++ 19 files changed, 669 insertions(+), 63 deletions(-) create mode 100644 test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/AbstractColl.xphp create mode 100644 test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/Book.xphp create mode 100644 test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/Collection.xphp create mode 100644 test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/ListColl.xphp create mode 100644 test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/OrderedCollection.xphp create mode 100644 test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/Product.xphp create mode 100644 test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/Use.xphp create mode 100644 test/fixture/compile/enclosing_bound_subinterface_direct_emit/verify/runtime.php create mode 100644 test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/AbstractColl.xphp create mode 100644 test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/Book.xphp create mode 100644 test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/Collection.xphp create mode 100644 test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/ListColl.xphp create mode 100644 test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/OrderedCollection.xphp create mode 100644 test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/Product.xphp create mode 100644 test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/Use.xphp create mode 100644 test/fixture/compile/enclosing_bound_subinterface_structural_class_param/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index b0c80f3..25fc2d0 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -126,7 +126,7 @@ public function compile( // Phase 2: fixed-point specialization loop. /** @var array $specializedAsts keyed by generated FQCN */ $specializedAsts = []; - $closer = new SpecializationCloser($hierarchy, new VarianceSubtyping($hierarchy)); + $closer = new SpecializationCloser($hierarchy, new VarianceSubtyping($hierarchy), $this->specializer, $this->hashLength); $depth = 0; while (true) { $countBefore = count($registry->instantiations()); @@ -161,7 +161,7 @@ public function compile( // needs the concrete supertype specialization that implements it, which the substitution // walk above never discovers (an upcast is usage, not substitution). Schedules it here so // the next iteration specializes it; the variance edge emitter then inherits the member. - $closerAdded = $closer->close($registry); + $closerAdded = $closer->close($registry, $specializedAsts); $countAfter = count($registry->instantiations()); if (!$newlyProcessed && !$closerAdded) { diff --git a/src/Transpiler/Monomorphize/SpecializationCloser.php b/src/Transpiler/Monomorphize/SpecializationCloser.php index 9d85146..ac67e9f 100644 --- a/src/Transpiler/Monomorphize/SpecializationCloser.php +++ b/src/Transpiler/Monomorphize/SpecializationCloser.php @@ -47,15 +47,22 @@ public function __construct( private TypeHierarchy $hierarchy, private VarianceSubtyping $subtyping, + private Specializer $specializer, + private int $hashLength, ) { } /** - * Schedule the concrete supertype specializations required by the covariant upcasts present in - * the registry. Returns true when it recorded at least one new instantiation, so the caller - * re-runs the fixed-point loop to specialize it. + * Close the specialization set for the covariant upcasts present in the registry. Two paths supply + * an upcast's erased member: scheduling the declaring class so the variance edge inherits it (the + * primary, WI-09 path — records a new instantiation), or — when inheritance can't carry it (the + * declaring class has a source parent, or implements only a parent interface) — emitting the member + * directly onto the upcast-source's specialized class. Returns true when it recorded at least one new + * instantiation, so the caller re-runs the fixed-point loop. + * + * @param array $specializedAsts keyed by generated FQCN */ - public function close(Registry $registry): bool + public function close(Registry $registry, array &$specializedAsts): bool { $countBefore = count($registry->instantiations()); @@ -87,7 +94,7 @@ public function close(Registry $registry): bool foreach ($interfaceSpecs as [$interfaceSpec, $methodNames]) { foreach ($concreteSpecs as $concreteSpec) { - $this->closeOne($interfaceSpec, $methodNames, $concreteSpec, $registry); + $this->closeOne($interfaceSpec, $methodNames, $concreteSpec, $registry, $specializedAsts); } } @@ -101,12 +108,14 @@ public function close(Registry $registry): bool /** * @param list $methodNames erasable method names declared on the interface + * @param array $specializedAsts */ private function closeOne( GenericInstantiation $interfaceSpec, array $methodNames, GenericInstantiation $concreteSpec, Registry $registry, + array &$specializedAsts, ): void { $interfaceFqn = $interfaceSpec->templateFqn; $concreteFqn = $concreteSpec->templateFqn; @@ -152,72 +161,194 @@ private function closeOne( } foreach ($methodNames as $methodName) { - $this->scheduleImplementer($interfaceSpec, $concreteFqn, $methodName, $registry); + $this->scheduleImplementer($interfaceSpec, $concreteSpec, $methodName, $registry, $specializedAsts); } } /** - * Schedule the class that declares `$methodName`'s body, specialized at the interface spec's - * arguments, so the concrete subtype inherits a concrete member through the covariant chain. + * Supply `$methodName`'s erased member for the upcast. Primary path (WI-09): when the body's + * declaring class is parent-less and threads its parameters through to this interface unchanged, + * schedule it at the supertype args and let the variance edge inherit it. Otherwise — the declaring + * class has a source parent (so the covariant edge can't be emitted under single inheritance), or it + * doesn't thread to this interface (it implements only a parent interface, or reorders its params) — + * emit the member directly onto the upcast-source class instead. Only a body that can't be found on + * any `Class_` in the ancestry (trait-supplied / interface-only) hard-fails. + * + * @param array $specializedAsts */ private function scheduleImplementer( GenericInstantiation $interfaceSpec, - string $concreteFqn, + GenericInstantiation $concreteSpec, string $methodName, Registry $registry, + array &$specializedAsts, ): void { - $declaring = $this->declaringClassWithBody($concreteFqn, $methodName, $registry); + $declaring = $this->declaringClassWithBody($concreteSpec->templateFqn, $methodName, $registry); if ($declaring === null) { throw new RuntimeException($this->unschedulableMessage( $interfaceSpec, $methodName, 'its implementation is not declared on a class in the hierarchy (a trait-supplied or ' - . 'interface-only body cannot be inherited through the covariant edge)', + . 'interface-only body cannot be inherited through the covariant edge, nor emitted directly)', )); } + $supertypeArgs = $interfaceSpec->concreteTypes; $declaringDef = $registry->definition($declaring); - // @infection-ignore-all -- `?->` is defensive: declaringClassWithBody() returned $declaring - // precisely because registry->definition($declaring) resolved to a class definition carrying - // the method, so it is never null here. + // @infection-ignore-all -- `?->` is defensive: declaringClassWithBody() returned $declaring via a + // resolved class definition, so registry->definition($declaring) is non-null here. $declaringAst = $declaringDef?->templateAst; - // If the declaring class itself has a source parent, the covariant edge that would carry the - // member (` extends `) cannot be emitted (PHP single - // inheritance — the source parent must win), so the member would not be inherited. Fail loudly. - if ($declaringAst instanceof Class_ && $declaringAst->extends !== null) { + $parentLess = $declaringAst instanceof Class_ && $declaringAst->extends === null; + $threaded = $this->hierarchy->resolveInheritedArgs($declaring, $supertypeArgs, $interfaceSpec->templateFqn); + $threadsIdentically = $threaded !== null && $this->argsEqual($threaded, $supertypeArgs); + + if ($parentLess && $threadsIdentically) { + // Inheritance can carry it: schedule the declaring class at the supertype args; Phase 2.5 + // emits ` extends ` and the member is inherited. + $registry->recordInstantiation($declaring, $supertypeArgs); + return; + } + + $this->emitDirectly($interfaceSpec, $concreteSpec, $declaring, $methodName, $registry, $specializedAsts); + } + + /** + * Emit the erased member directly onto the upcast-source class's specialized AST. The member's NAME + * and parameter come from the interface's declaration at the supertype args (so it byte-matches the + * abstract member `` exposes); the BODY comes from the declaring class, specialized + * with a SPLIT substitution — the declaring class's own parameters take the upcast-source's concretes + * (so the body reads the inherited backing state), while the bounded method parameter widens to the + * supertype arg. Sound because `sub <: super`. Self-contained (no covariant edge). + * + * @param array $specializedAsts + */ + private function emitDirectly( + GenericInstantiation $interfaceSpec, + GenericInstantiation $concreteSpec, + string $declaringFqn, + string $methodName, + Registry $registry, + array &$specializedAsts, + ): void { + $interfaceDef = $registry->definition($interfaceSpec->templateFqn); + $declaringDef = $registry->definition($declaringFqn); + // @infection-ignore-all -- defensive: both definitions resolved upstream (the interface spec + // entered $interfaceSpecs with a definition; declaringClassWithBody returned a defined class). + if ($interfaceDef === null || $declaringDef === null) { + return; + } + + // The supertype value the method's bound takes — from the INTERFACE's own declaration, so the + // mangled name matches the abstract member `` declares. + $interfaceMethod = self::methodNamed($interfaceDef->templateAst, $methodName); + // @infection-ignore-all -- defensive `?->`: $interfaceMethod always resolves here (see below). + $interfaceParams = $interfaceMethod?->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + // @infection-ignore-all -- defensive: the method name came from this interface's + // erasableMethodNames(), so $interfaceMethod and its method-generic params always resolve here; + // the `?->` and this null / non-array guard never fire. + if ($interfaceMethod === null || !is_array($interfaceParams)) { + return; + } + /** @var list $interfaceParams */ + $boundReferent = self::boundReferentName($interfaceParams); + // @infection-ignore-all FalseValue -- `false` vs `true` is equivalent: either non-int $boundIndex + // routes a null bound-referent to the same hard-fail (`!is_int(...)` below). + $boundIndex = $boundReferent === null + ? false + : array_search($boundReferent, $interfaceDef->typeParamNames(), true); + if (!is_int($boundIndex) || !isset($interfaceSpec->concreteTypes[$boundIndex])) { + // The parameters aren't uniformly bounded by ONE leaf interface parameter (a rare shape like + // ``). Direct emission can't derive the single mangled member — fail loudly + // rather than emit nothing and leave the interface's abstract member unimplemented. throw new RuntimeException($this->unschedulableMessage( $interfaceSpec, $methodName, - sprintf( - 'its declaring class "%s" already extends another class, so the covariant edge that ' - . 'would inherit the member cannot be emitted (PHP allows one parent)', - $declaring, - ), + 'its parameters are not uniformly bounded by one enclosing type parameter, so the ' + . 'member cannot be emitted directly', )); } + $superValue = $interfaceSpec->concreteTypes[$boundIndex]; - // Schedule the declaring class at the interface spec's arguments. This is valid only when the - // declaring class threads its parameters through to the interface identically — verify it. - // Schedule the declaring class at the interface spec's args only when it passes its parameters - // through to the interface UNCHANGED. A reordered/wrapped `implements` clause (the implementing - // spec can't be derived without inverting the mapping) or an unreachable target hard-fails — the - // closer never schedules a non-implementing specialization. (`$threaded === null` is the - // unreachable/ambiguous case; `!argsEqual` is the reorder/wrap case, covered by a reject test.) - $supertypeArgs = $interfaceSpec->concreteTypes; - $threaded = $this->hierarchy->resolveInheritedArgs($declaring, $supertypeArgs, $interfaceSpec->templateFqn); - if ($threaded === null || !$this->argsEqual($threaded, $supertypeArgs)) { + $mangled = Registry::mangledMethodName( + $methodName, + EnclosingBoundErasure::mangleArgs($interfaceParams, [$boundReferent => $superValue]), + $this->hashLength, + ); + + // Idempotency: never append a member the upcast-source spec already carries (its own erased + // member, or one already emitted) — a PHP redeclaration is a load fatal. + $upcastAst = $specializedAsts[$concreteSpec->generatedFqn] ?? null; + if (!$upcastAst instanceof Class_ || self::methodNamed($upcastAst, $mangled) !== null) { + return; + } + + // The body, from the declaring class, with the split substitution. + $declaringMethod = self::methodNamed($declaringDef->templateAst, $methodName); + // @infection-ignore-all -- defensive `?->`: $declaringMethod always resolves here (see below). + $declaringParams = $declaringMethod?->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + // @infection-ignore-all -- defensive: declaringClassWithBody found an erasable, bodied method of + // this name on this class, so it resolves here with method-generic params; the `?->` and this + // null / non-array guard never fire. + if ($declaringMethod === null || !is_array($declaringParams)) { + return; + } + /** @var list $declaringParams */ + $declaringConcrete = $this->hierarchy->resolveInheritedArgs( + $concreteSpec->templateFqn, + $concreteSpec->concreteTypes, + $declaringFqn, + ); + if ($declaringConcrete === null) { throw new RuntimeException($this->unschedulableMessage( $interfaceSpec, $methodName, - sprintf( - 'its declaring class "%s" does not pass its type parameters through to the interface ' - . 'unchanged, so the implementing specialization cannot be derived', - $declaring, - ), + sprintf('its body class "%s" can\'t be grounded against the upcast source', $declaringFqn), )); } + $subst = array_combine($declaringDef->typeParamNames(), $declaringConcrete); + foreach ($declaringParams as $param) { + $subst[$param->name] = $superValue; // the bounded method param widens to the supertype arg + } + + $member = $this->specializer->specializeMethod($declaringMethod, $subst, $mangled); + $upcastAst->stmts[] = $member; + // No re-collection of the body is needed: it substitutes the class parameter to the + // upcast-source's OWN concrete — the same value as the mandatory inherited erased member the + // upcast-source already carries at that arg — so any generic instantiation in the body has + // already been discovered and specialized through that member. + } + + /** The first method named `$name` on a ClassLike, or null. */ + private static function methodNamed(\PhpParser\Node\Stmt\ClassLike $ast, string $name): ?ClassMethod + { + foreach ($ast->getMethods() as $method) { + if ($method->name->toString() === $name) { + return $method; + } + } + return null; + } - $registry->recordInstantiation($declaring, $supertypeArgs); + /** + * The single enclosing-class parameter all the method's params are bounded by (the erasable shape), + * or null if the params aren't uniformly bounded by one leaf. + * + * @param list $params + */ + private static function boundReferentName(array $params): ?string + { + $referent = null; + foreach ($params as $param) { + if (!$param->bound instanceof BoundLeaf) { + return null; + } + $name = $param->bound->type->name; + if ($referent !== null && $referent !== $name) { + return null; + } + $referent = $name; + } + return $referent; } /** diff --git a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php index f2304dd..6f595fa 100644 --- a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php +++ b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php @@ -841,6 +841,45 @@ public function testCovariantInterfaceUpcastResolvesTheErasedMemberAtRuntime(): } } + #[RunInSeparateProcess] + public function testSubInterfaceMethodDirectEmittedUnderUpcastRunsAtRuntime(): void + { + // The headline case: `indexOf` on the sub-interface `OrderedCollection<+E>`, body on + // `ListColl extends AbstractColl` (a class with a parent). Upcast `ListColl` → + // `OrderedCollection` can't inherit `indexOf_` through a covariant edge, so it's + // emitted directly onto `ListColl` (reading its inherited Book-typed $items). `contains` + // still resolves via inheritance. Executed end to end. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/enclosing_bound_subinterface_direct_emit/source', + 'subiface-direct', + ); + try { + $fixture->registerAutoload('App'); + require __DIR__ . '/../../fixture/compile/enclosing_bound_subinterface_direct_emit/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testDirectEmittedBodyResolvesTheClassParamToTheUpcastSourceAtRuntime(): void + { + // The split-substitution gate: a directly-emitted body that references the class `E` structurally + // (`$value instanceof E`) must resolve `E` to the upcast-source's concrete (Book), not the + // supertype (Product). The call returns false ($value instanceof Book) — true would mean E wrongly + // became Product. Executed. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/enclosing_bound_subinterface_structural_class_param/source', + 'subiface-structural', + ); + try { + $fixture->registerAutoload('App'); + require __DIR__ . '/../../fixture/compile/enclosing_bound_subinterface_structural_class_param/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + #[RunInSeparateProcess] public function testMultiParamCovariantInterfaceUpcastResolvesAtRuntime(): void { @@ -1071,21 +1110,186 @@ function probe(Collection $c): bool { return $c->contains::(new Book self::assertSame([['App\\Book']], $abstractColl, 'no upcast → only AbstractColl, nothing extra scheduled'); } - public function testDeclaringClassWithASourceParentMakesTheUpcastUnschedulable(): void + public function testDirectEmittedMemberHasSupertypeParamAndUpcastSourceBody(): void { - // The implementation lives on a class that itself extends another class, so the covariant edge - // that would inherit it can't be emitted (single inheritance). The closer must hard-fail rather - // than emit class-load-fataling code. + // In-process (mutation-visible) proof of the split substitution: the directly-emitted member's + // PARAMETER widens to the supertype arg (Product), while the body's class parameter E resolves to + // the upcast-source's concrete (Book). A body `$value instanceof E` becomes `instanceof \App\Book` + // even though the parameter is `\App\Product`. The wrong (conflated) substitution would emit + // `instanceof \App\Product`, and a broken arg-name mapping would leave `E` unsubstituted. + $php = $this->generatedSourceFor([ + 'Product.xphp' => self::PRODUCT, + 'Book.xphp' => self::BOOK, + 'Collection.xphp' => self::COLLECTION_IFACE, + 'OrderedCollection.xphp' => <<<'PHP' + extends Collection { public function firstKind(U $value): bool; } + PHP, + 'AbstractColl.xphp' => self::ABSTRACT_COLL, + 'ListColl.xphp' => <<<'PHP' + extends AbstractColl implements OrderedCollection { + public function firstKind(U $value): bool { return $value instanceof E; } + } + PHP, + 'Use.xphp' => <<<'PHP' + $c): bool { return $c->firstKind::(new Product()); } + $l = new ListColl::(new Book()); + $r = probe($l); + PHP, + ], 'ListColl'); + + self::assertStringContainsString('\\App\\Product $value', $php, 'direct member parameter widens to the supertype arg'); + self::assertStringContainsString('instanceof \\App\\Book', $php, 'body class parameter E resolves to the upcast-source concrete (Book)'); + self::assertStringNotContainsString('instanceof \\App\\Product', $php, 'the split must NOT resolve the body E to the supertype (Product)'); + self::assertStringNotContainsString('instanceof E;', $php, 'the body class parameter E must be substituted (not left bare — the arg-name mapping)'); + // `contains` (parent-less base, threads identically) is supplied by INHERITANCE — its member must + // NOT also be emitted directly onto ListColl. (The inheritance path returns before direct emission.) + self::assertStringNotContainsString('contains_', $php, 'an inheritance-supplied member must not also be direct-emitted onto the upcast source'); + } + + public function testMultipleDistinctBoundsHardFailsDirectEmission(): void + { + // A method whose parameters are bounded by DIFFERENT enclosing parameters (``) + // isn't the uniformly-bounded shape direct emission can derive a single member for. Rather than + // silently emit nothing (leaving the abstract member unimplemented → load fatal), it hard-fails. $this->expectException(RuntimeException::class); - // Assert the code, the erased method name, the interface, and the remedy all appear — pins the - // diagnostic's content, not just that it threw. + // Pin the whole diagnostic — code, the erased method, the interface, the reason, and the remedy — + // so each operand of the shared message is asserted. $this->expectExceptionMessageMatches( - '/xphp\.unschedulable_covariant_upcast.+erased method "contains".+App\\\\Collection.+' - . 'already extends another class.+PHP allows one parent.+' - . 'Provide a concrete implementation.+covariant base class/s', + '/xphp\.unschedulable_covariant_upcast.+erased method "pick".+App\\\\BiColl.+' + . 'not uniformly bounded by one enclosing type parameter, so the member cannot be emitted ' + . 'directly.+Provide a concrete implementation.+move the method body onto the covariant ' + . 'base class/s', ); $this->compileResult([ + 'Product.xphp' => self::PRODUCT, + 'Book.xphp' => self::BOOK, + 'Tag.xphp' => " <<<'PHP' + { public function pick(U $a, V $b): bool; } + PHP, + 'Mid.xphp' => " {}\n", + 'ListColl.xphp' => <<<'PHP' + extends Mid implements BiColl { + public function pick(U $a, V $b): bool { return true; } + } + PHP, + 'Use.xphp' => <<<'PHP' + $c): bool { return $c->pick::(new Product(), new Tag()); } + $l = new ListColl::(); + $r = probe($l); + PHP, + ]); + } + + public function testTwoSameBoundParamsDirectEmitUnderUpcast(): void + { + // A method with two parameters both bounded by the SAME enclosing parameter (``) + // is uniformly bounded, so direct emission derives one member mangled on [E, E] at the supertype + // args. Compiles (no hard-fail), emitted directly (no scheduling). + $result = $this->compileResult([ + 'Product.xphp' => self::PRODUCT, + 'Book.xphp' => self::BOOK, + 'Collection.xphp' => self::COLLECTION_IFACE, + 'OrderedCollection.xphp' => <<<'PHP' + extends Collection { public function eitherIn(U $a, V $b): bool; } + PHP, + 'AbstractColl.xphp' => self::ABSTRACT_COLL, + 'ListColl.xphp' => <<<'PHP' + extends AbstractColl implements OrderedCollection { + public function eitherIn(U $a, V $b): bool { + return \in_array($a, $this->items, true) || \in_array($b, $this->items, true); + } + } + PHP, + 'Use.xphp' => <<<'PHP' + $c): bool { return $c->eitherIn::(new Product(), new Product()); } + $l = new ListColl::(new Book()); + $r = probe($l); + PHP, + ]); + + self::assertSame([['App\\Book']], self::specializationsOf($result, 'App\\ListColl'), 'two same-bound params emit directly, no scheduling'); + } + + public function testBodyOnAParentInterfaceBaseEmitsDirectlyForTheSubInterface(): void + { + // Case 2: the body sits on a parent-less base that implements only the PARENT interface + // (`Collection`), so it can't thread to the sub-interface (`OrderedCollection`) for scheduling — + // direct emission onto the upcast source supplies it instead. + $result = $this->compileResult([ + 'Product.xphp' => self::PRODUCT, + 'Book.xphp' => self::BOOK, + 'Collection.xphp' => self::COLLECTION_IFACE, + 'OrderedCollection.xphp' => <<<'PHP' + extends Collection { public function indexOf(U $value): int; } + PHP, + 'AbstractColl.xphp' => <<<'PHP' + implements Collection { + /** @var list */ + protected array $items; + public function __construct(E ...$items) { $this->items = $items; } + public function contains(U $value): bool { return \in_array($value, $this->items, true); } + public function indexOf(U $value): int { return \count($this->items); } + } + PHP, + 'ListColl.xphp' => " extends AbstractColl implements OrderedCollection {}\n", + 'Use.xphp' => <<<'PHP' + $c): int { return $c->indexOf::(new Product()); } + $l = new ListColl::(new Book()); + $r = probe($l); + PHP, + ]); + + // contains is scheduled via inheritance (AbstractColl); indexOf is emitted directly onto + // ListColl — so AbstractColl IS scheduled (for contains) but no ListColl is. + self::assertSame([['App\\Book']], self::specializationsOf($result, 'App\\ListColl'), 'indexOf emitted directly, ListColl not scheduled'); + self::assertContains(['App\\Product'], self::specializationsOf($result, 'App\\AbstractColl'), 'contains still scheduled via inheritance'); + } + + public function testDeclaringClassWithASourceParentEmitsTheMemberDirectly(): void + { + // The implementation lives on a class that itself extends another class, so the member can't be + // inherited through a covariant edge (single inheritance). Rather than hard-fail, the closer emits + // it DIRECTLY onto the upcast-source class — so no supertype spec is scheduled, and compile + // succeeds. + $result = $this->compileResult([ 'Product.xphp' => self::PRODUCT, 'Book.xphp' => self::BOOK, 'Collection.xphp' => self::COLLECTION_IFACE, @@ -1098,7 +1302,7 @@ class ListColl<+E> extends Mid implements Collection { /** @var list */ protected array $items; public function __construct(E ...$items) { $this->items = $items; } - public function contains(E2 $value): bool { return \in_array($value, $this->items, true); } + public function contains(U $value): bool { return \in_array($value, $this->items, true); } } PHP, 'Use.xphp' => <<<'PHP' @@ -1110,22 +1314,18 @@ function probe(Collection $c): bool { return $c->contains::(ne $r = probe($l); PHP, ]); + + // Direct emission, not scheduling: only ListColl exists — no ListColl / Mid. + self::assertSame([['App\\Book']], self::specializationsOf($result, 'App\\ListColl'), 'no supertype spec scheduled — emitted directly'); } - public function testReorderedImplementsClauseMakesTheUpcastUnschedulable(): void + public function testReorderedImplementsClauseEmitsTheMemberDirectly(): void { // The declaring class implements the interface with its parameters REORDERED - // (`class Holder<+A, B> implements Pair`), so the closer cannot derive the implementing - // specialization from the supertype's args (it would need to invert the mapping). It must - // hard-fail with the "does not pass its type parameters through" diagnostic, not schedule a - // wrong specialization. - $this->expectException(RuntimeException::class); - $this->expectExceptionMessageMatches( - '/xphp\.unschedulable_covariant_upcast.+does not pass its type parameters through to the ' - . 'interface unchanged, so the implementing specialization cannot be derived/s', - ); - - $this->compileResult([ + // (`class Holder<+A, B> implements Pair`), so the implementing spec can't be derived by + // inversion for scheduling. Direct emission doesn't need to invert — it emits onto the + // upcast-source class with the supertype's bound value — so this now compiles (no scheduled spec). + $result = $this->compileResult([ 'Product.xphp' => self::PRODUCT, 'Book.xphp' => self::BOOK, 'Id.xphp' => " $p): bool { return $p->contains::(new $r = probe($h); PHP, ]); + + // Emitted directly onto Holder — no reordered supertype spec scheduled. + self::assertSame([['App\\Book', 'App\\Id']], self::specializationsOf($result, 'App\\Holder'), 'reorder emits directly, no scheduling'); } public function testBareInstanceMethodGenericCallFailsCompile(): void @@ -1521,6 +1724,46 @@ private function compileResult(array $files): CompileResult return $compiler->compile($sources, $src, $src . '/dist', $src . '/.xphp-cache'); } + /** + * Compile `$files` and return the generated specialized PHP for one template (every spec of it), + * concatenated. Lets a test assert the SHAPE the closer's direct emission produces (member name + + * parameter type + body) in-process, where mutation testing can attribute kills — the runtime + * fixtures execute the output but run in separate processes. + * + * @param array $files + */ + private function generatedSourceFor(array $files, string $templateSegment): string + { + $src = $this->work . '/' . uniqid('src', true); + mkdir($src, 0o755, true); + foreach ($files as $name => $contents) { + file_put_contents($src . '/' . $name, $contents); + } + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + $compiler = new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + $sources = (new NativeFileFinder())->find($src) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + $compiler->compile($sources, $src, $src . '/dist', $src . '/.xphp-cache'); + + $dir = $src . '/.xphp-cache/Generated/App/' . $templateSegment; + $out = ''; + foreach (is_dir($dir) ? (scandir($dir) ?: []) : [] as $entry) { + if (str_ends_with($entry, '.php')) { + $out .= (string) file_get_contents($dir . '/' . $entry); + } + } + return $out; + } + /** * The concrete type-argument lists recorded for a given template FQN, each as a list of canonical * argument strings. Lets a test assert exactly which specializations of a class were scheduled. diff --git a/test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/AbstractColl.xphp b/test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/AbstractColl.xphp new file mode 100644 index 0000000..74d8b56 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/AbstractColl.xphp @@ -0,0 +1,21 @@ + implements Collection +{ + /** @var list */ + protected array $items; + + public function __construct(E ...$items) + { + $this->items = $items; + } + + public function contains(U $value): bool + { + return \in_array($value, $this->items, true); + } +} diff --git a/test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/Book.xphp b/test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/Book.xphp new file mode 100644 index 0000000..ad1d8fc --- /dev/null +++ b/test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/Book.xphp @@ -0,0 +1,9 @@ + +{ + public function contains(U $value): bool; +} diff --git a/test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/ListColl.xphp b/test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/ListColl.xphp new file mode 100644 index 0000000..2c49b48 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/ListColl.xphp @@ -0,0 +1,18 @@ + extends AbstractColl implements OrderedCollection +{ + public function indexOf(U $value): int + { + foreach ($this->items as $i => $item) { + if ($item === $value) { + return $i; + } + } + return -1; + } +} diff --git a/test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/OrderedCollection.xphp b/test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/OrderedCollection.xphp new file mode 100644 index 0000000..0f66e86 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/OrderedCollection.xphp @@ -0,0 +1,10 @@ + extends Collection +{ + public function indexOf(U $value): int; +} diff --git a/test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/Product.xphp b/test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/Product.xphp new file mode 100644 index 0000000..298054a --- /dev/null +++ b/test/fixture/compile/enclosing_bound_subinterface_direct_emit/source/Product.xphp @@ -0,0 +1,9 @@ + through a covariant edge, so it's emitted +// directly onto ListColl. contains_ still comes via inheritance from AbstractColl. +function firstProduct(OrderedCollection $c): int +{ + return $c->indexOf::(new Product()); +} + +function hasProduct(Collection $c): bool +{ + return $c->contains::(new Product()); +} + +$books = new ListColl::(new Book()); +$idx = firstProduct($books); // indexOf via direct emission +$has = hasProduct($books); // contains via inheritance diff --git a/test/fixture/compile/enclosing_bound_subinterface_direct_emit/verify/runtime.php b/test/fixture/compile/enclosing_bound_subinterface_direct_emit/verify/runtime.php new file mode 100644 index 0000000..c0368eb --- /dev/null +++ b/test/fixture/compile/enclosing_bound_subinterface_direct_emit/verify/runtime.php @@ -0,0 +1,23 @@ +` is upcast to `OrderedCollection` and its erased `indexOf` is called. The + * body lives on `ListColl`, which has a parent (`AbstractColl`), so the member can't be inherited through + * a covariant edge — it must be emitted directly onto `ListColl`, with the parameter widened to + * `Product` and the body reading the inherited `Book`-typed `$items` (Book <: Product). `contains` still + * resolves via the inheritance path. That both calls run proves direct emission and inheritance coexist. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertSame(-1, $idx, 'indexOf via direct emission must resolve, run, and report the fresh Product absent'); +Assert::assertFalse($has, 'contains via inheritance must still resolve'); +echo "OK\n"; diff --git a/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/AbstractColl.xphp b/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/AbstractColl.xphp new file mode 100644 index 0000000..74d8b56 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/AbstractColl.xphp @@ -0,0 +1,21 @@ + implements Collection +{ + /** @var list */ + protected array $items; + + public function __construct(E ...$items) + { + $this->items = $items; + } + + public function contains(U $value): bool + { + return \in_array($value, $this->items, true); + } +} diff --git a/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/Book.xphp b/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/Book.xphp new file mode 100644 index 0000000..ad1d8fc --- /dev/null +++ b/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/Book.xphp @@ -0,0 +1,9 @@ + +{ + public function contains(U $value): bool; +} diff --git a/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/ListColl.xphp b/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/ListColl.xphp new file mode 100644 index 0000000..c122553 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/ListColl.xphp @@ -0,0 +1,16 @@ + extends AbstractColl implements OrderedCollection +{ + // Body references the CLASS parameter E structurally (`instanceof E`). Under the upcast this member + // is emitted directly onto ListColl; E must resolve to the upcast-source's concrete (Book), + // NOT the supertype (Product). So `$value instanceof E` asks "is the argument a Book?". + public function firstKind(U $value): bool + { + return $value instanceof E; + } +} diff --git a/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/OrderedCollection.xphp b/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/OrderedCollection.xphp new file mode 100644 index 0000000..7d13d98 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/OrderedCollection.xphp @@ -0,0 +1,10 @@ + extends Collection +{ + public function firstKind(U $value): bool; +} diff --git a/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/Product.xphp b/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/Product.xphp new file mode 100644 index 0000000..298054a --- /dev/null +++ b/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/source/Product.xphp @@ -0,0 +1,9 @@ + $c): bool +{ + return $c->firstKind::(new Product()); +} + +$books = new ListColl::(new Book()); +$result = isBook($books); diff --git a/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/verify/runtime.php b/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/verify/runtime.php new file mode 100644 index 0000000..16e5a08 --- /dev/null +++ b/test/fixture/compile/enclosing_bound_subinterface_structural_class_param/verify/runtime.php @@ -0,0 +1,22 @@ +` under + * the upcast, with the parameter widened to `Product` but the class `E` resolving to the upcast-source's + * `Book`. So at runtime the call (with a fresh `Product`) evaluates `$value instanceof Book` → false. If the + * split substitution were wrong and `E` resolved to `Product`, it would be `$value instanceof Product` → + * true. The false answer proves the class `E` was substituted with `Book`, not `Product`. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertFalse($result, 'the body class parameter E must substitute to the upcast-source concrete (Book), not the supertype (Product)'); +echo "OK\n"; From 43c36010119365c39693a4b1f4847253c8c04b7a Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 25 Jun 2026 21:57:40 +0000 Subject: [PATCH 106/114] fix(monomorphize): reject direct emission when the enclosing parameter is in the return type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direct emission grounds the body's enclosing class parameter to the upcast source's own concrete element (a subtype of the supertype the member is emitted at), while the bounded method parameter widens to the supertype. That split is sound for body reads, but when the enclosing parameter also appears in the method's return type the emitted member returns the widened (supertype) value through a subtype return type — a runtime TypeError. The inheritance path grounds the whole member at one argument and handles this shape; direct emission cannot, so it now fails loudly with the upcast diagnostic instead of emitting a member that fatals when it runs. Only the return type is inspected: an enclosing parameter is covariant, so variance checking already forbids it from any parameter position before the closer runs, making the return type the sole signature slot it can occupy. A regression test pins the runtime-fataling shape as a loud compile-time failure, and a companion test pins the variance ordering that makes the return-type-only check complete. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/EnclosingBoundErasure.php | 20 +++++ .../Monomorphize/SpecializationCloser.php | 14 +++ .../EnclosingParamBoundIntegrationTest.php | 86 +++++++++++++++++++ 3 files changed, 120 insertions(+) diff --git a/src/Transpiler/Monomorphize/EnclosingBoundErasure.php b/src/Transpiler/Monomorphize/EnclosingBoundErasure.php index 5a405e6..a854446 100644 --- a/src/Transpiler/Monomorphize/EnclosingBoundErasure.php +++ b/src/Transpiler/Monomorphize/EnclosingBoundErasure.php @@ -78,6 +78,26 @@ public static function isErasable(ClassMethod $method, array $methodParams, arra return !self::bodyUsesBounded($method, $bounded); } + /** + * Whether the method's **return type** references one of the enclosing class type parameters (`E`). + * Used by direct upcast emission: that path grounds the body's class parameter to the upcast + * source's OWN concrete (a subtype of the supertype the member is emitted at), which is sound for + * body reads but NOT for a return position — a return type grounded to the subtype while the bounded + * parameter widens to the supertype lets a supertype value escape through a subtype return (a runtime + * `TypeError`). Such a shape can't be emitted directly and must fail loudly. + * + * Only the return type is inspected: an enclosing parameter is covariant (`+E`), so variance checking + * forbids it from appearing in any method *parameter* position before this point — the return type is + * the only signature position it can legally occupy. A bounded method parameter is typed by the + * *method* generic (`U`), not by `E`, so it never matches here either. + * + * @param list $enclosingParamNames + */ + public static function returnTypeReferencesEnclosing(ClassMethod $method, array $enclosingParamNames): bool + { + return self::typeReferencesBounded($method->returnType, $enclosingParamNames); + } + /** * Whether a type node (a parameter/return type: a Name, a nullable/union/intersection of them) * mentions a bounded name — bare (`U`) or nested in another type's args (`Box`). diff --git a/src/Transpiler/Monomorphize/SpecializationCloser.php b/src/Transpiler/Monomorphize/SpecializationCloser.php index ac67e9f..008f228 100644 --- a/src/Transpiler/Monomorphize/SpecializationCloser.php +++ b/src/Transpiler/Monomorphize/SpecializationCloser.php @@ -293,6 +293,20 @@ private function emitDirectly( return; } /** @var list $declaringParams */ + // The split substitution grounds the body's class parameter to the upcast source's OWN concrete + // (a subtype of the supertype the member is emitted at). That is sound for body reads, but if the + // class parameter also appears in the RETURN type, the member would return a supertype value (the + // widened bounded parameter) through a subtype return — a runtime TypeError. Direct emission can't + // ground that soundly; fail loudly instead. (A covariant parameter can't appear in an input + // position, so the return type is the only signature slot it can occupy.) + if (EnclosingBoundErasure::returnTypeReferencesEnclosing($declaringMethod, $declaringDef->typeParamNames())) { + throw new RuntimeException($this->unschedulableMessage( + $interfaceSpec, + $methodName, + 'its enclosing type parameter appears in the method return type, which direct emission ' + . 'cannot ground soundly against the upcast source', + )); + } $declaringConcrete = $this->hierarchy->resolveInheritedArgs( $concreteSpec->templateFqn, $concreteSpec->concreteTypes, diff --git a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php index 6f595fa..66cf158 100644 --- a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php +++ b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php @@ -1200,6 +1200,92 @@ function probe(BiColl $c): bool { return $c->pick::( ]); } + public function testEnclosingParamInSignatureHardFailsDirectEmission(): void + { + // The bounded method param widens to the supertype arg (Product), but the body's class param is + // grounded to the upcast source's OWN concrete (Book). When that class param also appears in the + // SIGNATURE — here a `: E` return type — the directly-emitted member would return a Product value + // (the widened fallback) through a `: Book` return, a runtime TypeError. The inheritance path + // grounds the whole member at one arg and handles this, but direct emission cannot, so it must + // hard-fail at compile time rather than emit a member that fatals when it runs. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches( + '/xphp\.unschedulable_covariant_upcast.+erased method "firstOr".+App\\\\OrderedCollection.+' + . 'enclosing type parameter appears in the method return type, which direct emission cannot ' + . 'ground soundly.+Provide a concrete implementation/s', + ); + + $this->compileResult([ + 'Product.xphp' => self::PRODUCT, + 'Book.xphp' => self::BOOK, + 'Collection.xphp' => self::COLLECTION_IFACE, + 'OrderedCollection.xphp' => <<<'PHP' + extends Collection { public function firstOr(U $fallback): E; } + PHP, + 'AbstractColl.xphp' => self::ABSTRACT_COLL, + 'ListColl.xphp' => <<<'PHP' + extends AbstractColl implements OrderedCollection { + public function firstOr(U $fallback): E { return $this->items[0] ?? $fallback; } + } + PHP, + 'Use.xphp' => <<<'PHP' + $c): Product { return $c->firstOr::(new Product()); } + $l = new ListColl::(); + $r = first($l); + PHP, + ]); + } + + public function testEnclosingParamInParameterPositionIsRejectedByVarianceFirst(): void + { + // Why the return-type guard need only inspect the return type: a covariant `+E` can never reach + // direct emission in a parameter position, because variance checking rejects `+E` in an input + // position long before the closer runs. This pins that ordering — the diagnostic is the variance + // error, NOT the upcast hard-fail — so the return-type-only guard is provably complete. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/`\+E` appears in method parameter position/'); + + $this->compileResult([ + 'Product.xphp' => self::PRODUCT, + 'Book.xphp' => self::BOOK, + 'Collection.xphp' => self::COLLECTION_IFACE, + 'OrderedCollection.xphp' => <<<'PHP' + extends Collection { public function pairContains(U $value, E $other): bool; } + PHP, + 'AbstractColl.xphp' => self::ABSTRACT_COLL, + 'ListColl.xphp' => <<<'PHP' + extends AbstractColl implements OrderedCollection { + public function pairContains(U $value, E $other): bool { + return \in_array($value, $this->items, true) && \in_array($other, $this->items, true); + } + } + PHP, + 'Use.xphp' => <<<'PHP' + $c, Product $p): bool { return $c->pairContains::($p, $p); } + $l = new ListColl::(); + $r = probe($l, new Product()); + PHP, + ]); + } + public function testTwoSameBoundParamsDirectEmitUnderUpcast(): void { // A method with two parameters both bounded by the SAME enclosing parameter (``) From 29a500004c676fdfcabe405b428f37eb2aa2d242 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 25 Jun 2026 22:08:31 +0000 Subject: [PATCH 107/114] docs: document direct emission of a covariant-upcast member; ADR for unmodeled traits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the direct-emission path for a covariant upcast whose member can't be inherited: the changelog and type-bounds guide now describe emitting the member onto the upcast source (parameter widened to the supertype, body read at the source's element type), the residual shapes that still hard-fail (no class body, an element-typed return, non-uniform bounds), and the body-reads-at-the-source-element semantics. Update the `xphp.unschedulable_covariant_upcast` reference to match. Add ADR-0019 recording that trait-imported members are deliberately not modeled in the type hierarchy — a trait-only body is a loud residual (`xphp.unschedulable_covariant_upcast`) rather than a silently missed member, consistent with the existing variance/bound trait-`use` boundary. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 14 ++- ...s-are-not-modeled-in-the-type-hierarchy.md | 87 +++++++++++++++++++ docs/adr/README.md | 1 + docs/errors.md | 2 +- docs/syntax/type-bounds.md | 44 +++++++--- 5 files changed, 133 insertions(+), 15 deletions(-) create mode 100644 docs/adr/0019-trait-members-are-not-modeled-in-the-type-hierarchy.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 952c9f9..225f3b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,10 +47,16 @@ _In progress on this branch — content still accumulating; date set at tag time (`xphp.unspecializable_self_call` / `xphp.bound_unprovable`) — never a runtime fault. **Covariant interfaces:** the method may be declared on a covariant interface (`Collection<+E>`) and called through an upcast (`ListColl` used as `Collection`) — the implementer specialization - is scheduled and inherited down the covariant chain automatically. When it can't be carried there - (the implementing class has another parent, a trait-only body, or a reordered `implements` clause) - the upcast is a compile error (`xphp.unschedulable_covariant_upcast`), never emitted load-fataling - code. See [type bounds](docs/syntax/type-bounds.md) and + is scheduled and inherited down the covariant chain automatically. When inheritance can't carry it + there — the implementing class has another `extends` parent, implements only a *parent* of the + interface, or reorders the `implements` clause — the member is instead emitted **directly** onto the + upcast source, with its bounded parameter widened to the supertype argument and its body read at the + source's own element type (sound because the source's element is a subtype of the supertype). The + upcast remains a compile error (`xphp.unschedulable_covariant_upcast`) — never emitted load- or + runtime-fataling code — only where no emittable class body exists (a truly abstract or trait-only + method), where the method's return type names the element parameter (the widened argument would + escape through a narrower return), or where its parameters are bounded by different enclosing + parameters (no single member can be derived). See [type bounds](docs/syntax/type-bounds.md) and [ADR-0018](docs/adr/0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md). - **Generic methods resolved through inheritance.** A generic method declared on a base or abstract class is now callable by turbofish on a *subclass* receiver — diff --git a/docs/adr/0019-trait-members-are-not-modeled-in-the-type-hierarchy.md b/docs/adr/0019-trait-members-are-not-modeled-in-the-type-hierarchy.md new file mode 100644 index 0000000..d7c1067 --- /dev/null +++ b/docs/adr/0019-trait-members-are-not-modeled-in-the-type-hierarchy.md @@ -0,0 +1,87 @@ +# 19. Trait-imported members are not modeled in the type hierarchy + +- Status: Accepted — 2026-06 + +## Context and Problem Statement + +When a value is upcast to a covariant interface — `ListColl` used as +`Collection` — the element-consuming method (`contains`) must be supplied as a +concrete member at the supertype argument. The closer satisfies it one of two ways: **inherit** it +through the covariant `extends` chain (when the body sits on a parent-less covariant base), or, when +inheritance can't carry it, **emit** it directly onto the upcast source. Both paths first locate an +emittable **class** body for the method by walking the type hierarchy's ancestors. + +PHP also lets a class acquire a method body from a **trait** (`use SomeTrait;`). A trait is neither +a parent nor an interface; `TypeHierarchy` is built from `extends`/`implements` clauses and does not +record `use` edges. So a method whose only body is trait-supplied is **invisible** to the closer: +the hierarchy walk finds the method declared (abstractly, via the interface) but no class body to +inherit or copy. The question is what to do with that shape. + +Faithfully modelling traits is not a small addition. It would mean tracking `use` edges, then +honouring PHP's full trait semantics — method resolution order, `insteadof` conflict resolution, +`as` aliasing/visibility changes, abstract trait methods, and trait-on-trait composition — and +threading the imported, possibly-renamed members through the same parameterised-supertype machinery +the class hierarchy already uses. That is a self-contained feature, not a tweak to the upcast closer. + +## Decision Drivers + +- Soundness over coverage — a missing member must fail loudly, never silently emit load-fataling + output. +- Keep the upcast closer scoped — it reasons about the covariant `extends`/`implements` lattice; + trait composition is a separate concern. +- Don't ship a half-modelled trait system whose partial semantics mislead more than they help. + +## Considered Options + +- **Partially model traits** — record `use` edges and copy trait method bodies, ignoring conflict + resolution / aliasing / abstract trait methods. Cheap to start, but silently wrong the moment a + program uses any of the omitted semantics. +- **Fully model traits** — implement PHP's trait resolution end-to-end in `TypeHierarchy`. Correct, + but a large feature unrelated to the upcast work, and unneeded until a real program hits it. +- **Don't model traits; treat a trait-only body as a residual** — the hierarchy stays + `extends`/`implements`-only; a covariant-upcast member with no reachable *class* body fails loudly. + +## Decision Outcome + +Chosen: **traits are not modelled in the type hierarchy.** `TypeHierarchy` records only +`extends`/`implements` edges. When a covariant upcast needs a member whose only body would come from +a trait, no class body is found, so the upcast is a compile error +(`xphp.unschedulable_covariant_upcast`) — the same loud, actionable failure used for the other +shapes direct emission can't ground. The remedy is to move the element-consuming body onto the +covariant base **class** (where both the inheritance and direct-emission paths can reach it), which +is also the idiomatic place for it. + +This is deliberately consistent with the existing variance/bound caveat that bound and variance +rules are **not** recursively walked across trait `use` boundaries (see +[Caveats](../caveats.md#variance-validator-and-trait-use)): xphp does not currently follow generics +through traits in either direction. A trait-only covariant-upcast body falls under the same boundary +and fails the same way, rather than being a silent gap. + +### Consequences + +- Good: the upcast closer stays sound and small; an unsupported shape is a clear compile error with + a one-move remedy, never emitted code that fatals at load or run time. +- Good: no partially-correct trait semantics to mislead — the boundary is uniform with the existing + trait caveat. +- Trade-off: a library that puts an element-consuming method body in a trait and relies on it + through a covariant upcast must relocate that body to the covariant base class. Declaring the + method directly on the class is the supported shape. +- Reversible: if a real program needs it, modelling `use` edges (with full resolution semantics) is + an additive change behind the same diagnostic — the failure becomes a success without any + call-site change. + +### Confirmation + +Exercised by the covariant-upcast suite: a method whose body is supplied only through a trait +produces `xphp.unschedulable_covariant_upcast` rather than an emitted member, alongside the accepted +shapes where the body is on a parent-bearing class (direct emission) or a parent-less base +(inheritance). `TypeHierarchy` is built solely from `extends`/`implements` clauses; no `use` edge is +recorded. + +## More Information + +- [ADR-0018](0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md) — grounding + method-generic bounds on enclosing type parameters (the feature this boundary sits inside). +- [Type bounds](../syntax/type-bounds.md) — the covariant-upcast section and its residual cases. +- [Caveats](../caveats.md) — the variance/bound trait-`use` boundary this is consistent with. +- [Errors](../errors.md) — `xphp.unschedulable_covariant_upcast`. diff --git a/docs/adr/README.md b/docs/adr/README.md index 481fa9a..27cba74 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -36,3 +36,4 @@ should be added here as a new numbered file; copy | [0016](0016-no-special-cased-value-equality-bound.md) | No special-cased value-equality bound (use ordinary generics) | Accepted | | [0017](0017-config-manifest-source-resolution.md) | Config-manifest, multi-root source resolution (`xphp.json`) | Accepted | | [0018](0018-grounding-method-generic-bounds-on-enclosing-type-parameters.md) | Grounding a method-generic bound that references an enclosing class type parameter | Accepted | +| [0019](0019-trait-members-are-not-modeled-in-the-type-hierarchy.md) | Trait-imported members are not modeled in the type hierarchy | Accepted | diff --git a/docs/errors.md b/docs/errors.md index b70c1e0..3a1f51a 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -50,7 +50,7 @@ The `json` and `github` formats tag each diagnostic with a stable code: | `xphp.bound_unprovable` | a method-generic bound that references an enclosing class type parameter (`contains`) can't be proven because the receiver's type argument isn't determinable here — a raw `Box` with no argument, a branch whose arms disagree, a static call, or a `$this` self-call. Ground the receiver (bind it to a typed local) or the build fails | | `xphp.undetermined_receiver` | a turbofish method call's receiver has no statically-known type (an untyped `foreach` variable, a local whose type is ambiguous after a branch), so the call can't be specialized — it would emit a call to a stripped method that fatals at runtime. Give the receiver a declared type | | `xphp.unspecializable_self_call` | a `$this`-rooted self-call forwards a type parameter to a **non-erasable** generic method (one whose parameter is used nested, in the return, or structurally). Forwarding to an *erasable* method — parameter used only as a direct input — compiles and runs; otherwise move the call to a typed-receiver context | -| `xphp.unschedulable_covariant_upcast` | a value is upcast to a covariant *interface* whose element-consuming method (`contains`) needs a concrete implementation inherited through the covariant chain, but it can't be carried there: the implementing class has another `extends` parent, the method body lives only in a trait, or the class reorders the interface's parameters. Put the implementation on a parent-less covariant base that passes its parameters through unchanged | +| `xphp.unschedulable_covariant_upcast` | a value is upcast to a covariant *interface* whose element-consuming method (`contains`) needs a concrete implementation at the supertype argument that can neither be inherited through the covariant chain nor emitted directly onto the upcast source. Direct emission already covers the cases where inheritance can't carry it (the implementing class has another `extends` parent, implements only a parent of the interface, or reorders the clause); the upcast fails only when **no** emittable class body exists (a truly abstract or trait-only method), the method's **return type** names the element parameter (the widened argument would escape through a narrower return), or its parameters are bounded by **different** enclosing parameters (no single member can be derived). Provide a concrete implementation on a class — move a trait body onto the covariant base, or give the method a non-element return type | | `xphp.parse_error` | the file isn't valid PHP after the generic strip pass | | `phpstan.*` | a PHPStan finding in the compiled output, mapped back to the template declaration (the code is `phpstan.` + PHPStan's own identifier, e.g. `phpstan.return.type`; a finding that carries no identifier falls back to the literal `phpstan.error`) — present only when the PHPStan pass runs | | `phpstan.unavailable` | (Warning) no phpstan binary was found, so the PHPStan pass was skipped | diff --git a/docs/syntax/type-bounds.md b/docs/syntax/type-bounds.md index 62704a2..ce0659d 100644 --- a/docs/syntax/type-bounds.md +++ b/docs/syntax/type-bounds.md @@ -199,11 +199,11 @@ covariant **upcast** — the shape a collections library uses: ```php interface Collection<+E> { - public function contains(E2 $value): bool; + public function contains(U $value): bool; } abstract class AbstractColl<+E> implements Collection { public function __construct(private E ...$items) {} - public function contains(E2 $value): bool { /* ... */ } + public function contains(U $value): bool { /* ... */ } } class ListColl<+E> extends AbstractColl {} @@ -217,15 +217,39 @@ anyProduct($books); // OK — upcast to Collection` has `contains_`, `Collection` has `contains_` — distinct, so the covariant edge never narrows a -parameter), and the concrete implementation is inherited through the covariant -chain. For that to work the element-consuming body must sit on a **parent-less -covariant base** that passes its type parameters straight to the interface — the -`AbstractColl<+E> implements Collection` shape above. If the implementing -class has another `extends` parent, supplies the body only through a trait, or -reorders the interface's parameters, the implementation can't be carried down a -single covariant chain, so the upcast is a compile error +parameter). When the element-consuming body sits on a **parent-less covariant +base** that passes its type parameters straight to the interface — the +`AbstractColl<+E> implements Collection` shape above — the implementation is +**inherited** through the covariant chain. + +When inheritance can't carry it — the implementing class has another `extends` +parent, implements only a *parent* of the interface, or reorders the +`implements` clause — the member is instead emitted **directly** onto the upcast +source. Its bounded parameter widens to the supertype argument (`U → Product`), +while the body reads the source's own element type (`E → Book`); that split is +sound because the source's element is a subtype of the supertype, so reading the +instance's own backing state through the widened parameter is type-safe. A class +upcast to several supertypes gets one such member each (distinct mangled names — +no redeclaration). + +> The body reads at the source's element type, not the supertype. For a method +> whose body inspects `E` structurally — `return $value instanceof E;` — the +> directly-emitted member tests against the source's concrete element (`Book`), +> the runtime instance's actual element type. + +A handful of shapes have no sound emittable member and remain a compile error (`xphp.unschedulable_covariant_upcast`) rather than a runtime fault — ground or -fail. +fail: + +- **No class body** — the method is truly abstract or its only body is supplied + through a trait (trait-imported members aren't modelled in the type hierarchy; + see [ADR-0019](../adr/0019-trait-members-are-not-modeled-in-the-type-hierarchy.md)). +- **The return type names the element parameter** — e.g. `first(U $fallback): E`. + Direct emission would return the widened (supertype) value through the narrower + element return type, a runtime `TypeError`. (Such a method still works through the + inheritance path, which grounds the whole member at one argument.) +- **Parameters bounded by different enclosing parameters** — `pick`. + No single member can be derived for a non-uniform bound. ## Caveats From 54b30860d8041107a1368773ff964729bd9cfd50 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 26 Jun 2026 05:50:30 +0000 Subject: [PATCH 108/114] docs(adr): show the alias and insteadof cases that make full trait modeling heavyweight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0019's "fully model traits" option now carries the two concrete shapes that make partial modeling unsound — `as` aliasing (the synthesized member must be found under the trait's original name and emitted under the alias) and `insteadof` conflict resolution (only one of two same-named trait bodies is authoritative) — so the option reads as the full-PHP-semantics feature it is, not a shortcut. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...s-are-not-modeled-in-the-type-hierarchy.md | 52 +++++++++++++++++-- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/docs/adr/0019-trait-members-are-not-modeled-in-the-type-hierarchy.md b/docs/adr/0019-trait-members-are-not-modeled-in-the-type-hierarchy.md index d7c1067..cca2b19 100644 --- a/docs/adr/0019-trait-members-are-not-modeled-in-the-type-hierarchy.md +++ b/docs/adr/0019-trait-members-are-not-modeled-in-the-type-hierarchy.md @@ -33,11 +33,53 @@ the class hierarchy already uses. That is a self-contained feature, not a tweak ## Considered Options -- **Partially model traits** — record `use` edges and copy trait method bodies, ignoring conflict - resolution / aliasing / abstract trait methods. Cheap to start, but silently wrong the moment a - program uses any of the omitted semantics. -- **Fully model traits** — implement PHP's trait resolution end-to-end in `TypeHierarchy`. Correct, - but a large feature unrelated to the upcast work, and unneeded until a real program hits it. +- **Partially model traits** — record `use` edges and copy the matching trait method body, ignoring + conflict resolution / aliasing / abstract trait methods / trait composition. Cheap to start, and it + does handle the trivial case (a single trait, one unambiguous body, whose name matches the + interface). But it is silently wrong the moment a program uses any of the omitted semantics — and + because the closer *synthesizes* a new member (not just keeps a runtime `use`), getting it wrong + emits the wrong member rather than failing. + +- **Fully model traits** — implement PHP's trait resolution end-to-end (method resolution order, + `insteadof` conflict resolution, `as` aliasing and visibility changes, abstract trait methods, and + trait-on-trait composition), threaded through the same parameterised-supertype machinery the class + hierarchy already uses. This is what *correctly* supplying a trait body requires — partial modeling + is unsound the instant resolution decides **which** body lands or **under what name**: + + - **`as` aliasing — the body's name differs from the interface's.** A trait method imported under an + alias is what satisfies the interface, so the synthesized member must be found under the trait's + *original* name and emitted under the *alias*: + + ```php + trait SearchOps<+E> { public function locate(U $value): int { /* scan $this->items */ } } + interface OrderedCollection<+E> extends Collection { public function indexOf(U $value): int; } + class ListColl<+E> extends LinkedNode implements OrderedCollection { + use SearchOps { locate as indexOf; } // the alias is what satisfies indexOf + } + ``` + + The abstract member is `indexOf` at the supertype argument; its body is the trait's `locate`. A + name-keyed copy looks for `indexOf` in `SearchOps`, finds nothing, and leaves the member + unimplemented — a load fatal. Only the alias map resolves it. + + - **`insteadof` — two trait bodies, only one wins.** When two `use`d traits both supply the method, + PHP's `insteadof` picks the authoritative body; a copy with no conflict resolution emits the wrong + algorithm (a silent correctness bug) or a duplicate: + + ```php + trait LinearSearch<+E> { public function contains(U $v): bool { /* O(n) scan */ } } + trait HashSearch<+E> { public function contains(U $v): bool { /* hash lookup */ } } + class FastColl<+E> extends RingBuffer implements Collection { + use LinearSearch, HashSearch { HashSearch::contains insteadof LinearSearch; } + } + ``` + + Only honouring `insteadof` emits `HashSearch::contains` as the supertype-argument member; a partial + copy cannot tell which body is correct. + + Correct, but a large, self-contained feature unrelated to the upcast work, and unneeded until a real + program hits one of these shapes. + - **Don't model traits; treat a trait-only body as a residual** — the hierarchy stays `extends`/`implements`-only; a covariant-upcast member with no reachable *class* body fails loudly. From 775bbbf877d227e3dd8903116ed9f95710dc94c8 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 26 Jun 2026 10:33:23 +0000 Subject: [PATCH 109/114] fix(monomorphize): emit the covariant edge for a cross-template generic type-argument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A covariant upcast whose type-argument is itself a generic of a different but provably-related template — `Couple, X>` viewed as `Tuple, X>` — was silently not emitted: the per-argument subtype check `isNestedSubtype` resolved both-non-generic and same-template pairs but returned a conservative false for a different-template generic pair, even when one template provably implements the other. So the variance edge was omitted, `xphp check` passed, and the upcast fatal'd at runtime. Add a cross-template branch: when the two args are generics of different templates and the child's template provably implements/extends the parent's, thread the child's args up to the parent's template (the same hierarchy helper the closer uses) and recurse under the parent's own variance — so `ImmutableList` resolves to `Collection`, then `Book` is compared against `Product` under `Collection`'s covariant element. Emit only on a positive subtype result; the recursion's arity guard rejects a malformed (non-null, wrong-arity) grounding, so a bare or over-supplied parameterized super never produces a bogus edge. The flip in `isVarianceSubtype` routes contravariant slots through the same branch, so the case composes symmetrically across variance. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/VarianceSubtyping.php | 49 ++++-- .../VarianceEdgeIntegrationTest.php | 46 ++++++ .../Monomorphize/VarianceSubtypingTest.php | 139 ++++++++++++++++++ .../source/Book.xphp | 7 + .../source/Collection.xphp | 10 ++ .../source/Couple.xphp | 20 +++ .../source/ImmutableList.xphp | 21 +++ .../source/Product.xphp | 7 + .../source/Tag.xphp | 7 + .../source/Tuple.xphp | 12 ++ .../source/Use.xphp | 20 +++ .../verify/runtime.php | 27 ++++ 12 files changed, 352 insertions(+), 13 deletions(-) create mode 100644 test/fixture/compile/cross_template_generic_arg_upcast/source/Book.xphp create mode 100644 test/fixture/compile/cross_template_generic_arg_upcast/source/Collection.xphp create mode 100644 test/fixture/compile/cross_template_generic_arg_upcast/source/Couple.xphp create mode 100644 test/fixture/compile/cross_template_generic_arg_upcast/source/ImmutableList.xphp create mode 100644 test/fixture/compile/cross_template_generic_arg_upcast/source/Product.xphp create mode 100644 test/fixture/compile/cross_template_generic_arg_upcast/source/Tag.xphp create mode 100644 test/fixture/compile/cross_template_generic_arg_upcast/source/Tuple.xphp create mode 100644 test/fixture/compile/cross_template_generic_arg_upcast/source/Use.xphp create mode 100644 test/fixture/compile/cross_template_generic_arg_upcast/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/VarianceSubtyping.php b/src/Transpiler/Monomorphize/VarianceSubtyping.php index fdd5fbd..1c5de1f 100644 --- a/src/Transpiler/Monomorphize/VarianceSubtyping.php +++ b/src/Transpiler/Monomorphize/VarianceSubtyping.php @@ -93,26 +93,35 @@ public function isVarianceSubtype(array $args1, array $args2, array $params, Reg * `Producer>` and `Producer>` relate when Box has covariant T — without * it, the comparison would flatten to `isSubtype('Box', 'Box') == true` and claim a relationship * even when the INNER args aren't subtype-related. - * - Otherwise (different templates, or one generic one not): conservative false. + * - Both generic of DIFFERENT templates: if `$child`'s template provably implements/extends + * `$parent`'s, thread `$child`'s args up to `$parent`'s template and recurse under `$parent`'s + * OWN variance — so a covariant container nested as a type-argument relates + * (`ImmutableList` as `Collection` → `ImmutableList implements Collection`, thread + * to `Collection`, then `Book ⊑ Product` under `Collection`'s covariant `E`). Stays + * conservative (no edge) unless the relationship is POSITIVELY grounded. + * - Otherwise (one generic, one not): conservative false. * - * @infection-ignore-all LogicalAnd UnwrapLtrim -- the both-generic / same-template guard `&&`s are - * equivalent for every reachable input: instantiation args are always FULLY parameterized - * TypeRefs, so a parameterized-vs-bare or different-template pairing (the only inputs where `&&` - * and `||` would diverge) never arises — a mixed/different pairing is rejected either way - * (conservative false). The `ltrim('\\')` calls are no-ops because registry/canonical names never - * carry a leading backslash (same defensive the hierarchy collector documents), so unwrapping them - * is equivalent. The meaningful comparisons — the leaf `isSubtype(...) === true` and the inner - * recursion — stay mutation-covered by VarianceSubtypingTest. + * Trust model — the fatal-vs-missed-edge asymmetry: a wrong "yes" emits a bogus `implements` edge + * that PHP-fatals at autoload, so emit only on a positive `isSubtype(...) === true` AND a threaded + * arg tuple the recursion's arity guard ({@see isVarianceSubtype}'s `count()` check) accepts — + * `resolveInheritedArgs` can return a NON-null but wrong-arity tuple (a bare or over-supplied + * parameterized super), so that recursion is load-bearing, not inert reuse. A missed "yes" only + * loses a relationship the compiler couldn't positively prove. + * + * @infection-ignore-all UnwrapLtrim -- the `ltrim('\\')` calls are no-ops: registry/canonical names + * never carry a leading backslash (the same defensive the hierarchy collector documents), so + * unwrapping them is equivalent. The meaningful guards — `isSubtype(...) === true`, the same- vs + * different-template routing, and the inner recursion — stay mutation-covered by VarianceSubtypingTest. */ private function isNestedSubtype(TypeRef $child, TypeRef $parent, Registry $registry): bool { if (!$child->isGeneric() && !$parent->isGeneric()) { return $this->hierarchy->isSubtype($child->name, $parent->name) === true; } - if ($child->isGeneric() && $parent->isGeneric() - && ltrim($child->name, '\\') === ltrim($parent->name, '\\') - ) { - $innerDef = $registry->definition(ltrim($child->name, '\\')); + $childName = ltrim($child->name, '\\'); + $parentName = ltrim($parent->name, '\\'); + if ($child->isGeneric() && $parent->isGeneric() && $childName === $parentName) { + $innerDef = $registry->definition($childName); $innerParams = $innerDef !== null ? $innerDef->typeParams : []; // @infection-ignore-all ReturnRemoval -- equivalent: removing this early return falls // through to isVarianceSubtype() with empty params, whose arity guard @@ -127,6 +136,20 @@ private function isNestedSubtype(TypeRef $child, TypeRef $parent, Registry $regi $registry, ); } + // Different-template generics: emit only if child's template provably implements/extends + // parent's. Thread child's args up to parent's template (the same helper SpecializationCloser + // uses) and recurse under parent's OWN params. resolveInheritedArgs may return a non-null + // wrong-arity tuple, so the recursion's count() guard is what rejects the malformed case -- never + // trust $threaded for being merely non-null. + if ($child->isGeneric() && $parent->isGeneric() + && $this->hierarchy->isSubtype($childName, $parentName) === true + ) { + $threaded = $this->hierarchy->resolveInheritedArgs($childName, $child->args, $parentName); + $parentDef = $registry->definition($parentName); + if ($threaded !== null && $parentDef !== null && $parentDef->typeParams !== []) { + return $this->isVarianceSubtype($threaded, $parent->args, $parentDef->typeParams, $registry); + } + } return false; } } diff --git a/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php b/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php index 7ff1672..324b2fd 100644 --- a/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php +++ b/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php @@ -141,6 +141,52 @@ public function testCovariantPrivatePropertyStoresRealTypeAndIsRuntimeChecked(): } } + #[RunInSeparateProcess] + public function testCrossTemplateGenericArgUpcastEmitsEdgeAndRunsAtRuntime(): void + { + // A covariant `Couple<+A, +B> implements Tuple` holding a covariant container + // `ImmutableList` as its first type-argument is upcast to `Tuple, + // Tag>`. That requires the covariant edge `Tuple, Tag> ⊑ + // Tuple, Tag>`, whose per-argument check must recognize `ImmutableList + // ⊑ Collection` ACROSS DIFFERENT TEMPLATES (ImmutableList implements Collection, both + // covariant). Before the cross-template case in `isNestedSubtype`, the edge was silently + // omitted: `xphp check` passed, then the upcast fatal'd at runtime. This proves the edge is now + // emitted AND the covariance holds when the program actually runs. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/cross_template_generic_arg_upcast/source', + 'cross-template-arg', + ); + try { + // The interface `Tuple` specializations carry the cross-template covariant edge: the + // `Tuple, Tag>` spec must `extends` the `Tuple, Tag>` + // spec (an interface-to-interface variance edge). Pre-fix there is no such edge at all. + $tupleDir = $fixture->cacheDir . '/Generated/App/Tuple'; + $tupleFiles = glob($tupleDir . '/T_*.php') ?: []; + self::assertGreaterThanOrEqual(2, count($tupleFiles), 'two Tuple specializations exist'); + $crossEdges = 0; + foreach ($tupleFiles as $file) { + $content = file_get_contents($file); + self::assertIsString($content); + // A Tuple spec whose `extends` clause references ANOTHER generated Tuple spec is the + // cross-template covariant edge (`Tuple,Tag> extends + // Tuple,Tag>`). The supertype spec extends only `\App\Tuple`. + if (str_contains($content, '\\XPHP\\Generated\\App\\Tuple\\T_')) { + $crossEdges++; + } + } + self::assertGreaterThanOrEqual( + 1, + $crossEdges, + 'the cross-template covariant edge between the two Tuple specializations is emitted', + ); + + $fixture->registerAutoload('App\\'); + require __DIR__ . '/../../fixture/compile/cross_template_generic_arg_upcast/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + public function testBoundedCovariantConstructorKeepsConcreteType(): void { // A bounded covariant constructor param keeps its REAL substituted type (the diff --git a/test/Transpiler/Monomorphize/VarianceSubtypingTest.php b/test/Transpiler/Monomorphize/VarianceSubtypingTest.php index 81af792..af65f34 100644 --- a/test/Transpiler/Monomorphize/VarianceSubtypingTest.php +++ b/test/Transpiler/Monomorphize/VarianceSubtypingTest.php @@ -192,4 +192,143 @@ public function testDifferentInnerTemplatesAreNotSubtype(): void $this->registry(), )); } + + // ---- Cross-template generic type-argument subtyping ---- + // + // `ImmutableList<+E> implements Collection<+E>`, `Book <: Product`. The hierarchy carries the + // PARAMETERISED supertype edge so `resolveInheritedArgs` can thread `ImmutableList` up to + // `Collection`. `Mid implements Collection` is the MALFORMED case — a 2-arg + // parameterised super against a 1-param target — exercising the load-bearing count() arity guard. + + private const COLLECTION = 'App\\Collection'; + private const IMMUTABLE_LIST = 'App\\ImmutableList'; + private const BOOK = 'App\\Book'; + private const PRODUCT = 'App\\Product'; + + private function crossHierarchy(): TypeHierarchy + { + return new TypeHierarchy( + ancestors: [ + self::IMMUTABLE_LIST => [self::COLLECTION], + self::BOOK => [self::PRODUCT], + 'App\\Mid' => [self::COLLECTION], + 'App\\Foo' => [], + 'App\\Bar' => [], + ], + superTypeArgs: [ + self::IMMUTABLE_LIST => [new TypeRef(self::COLLECTION, [new TypeRef('E', isTypeParam: true)])], + // Malformed: declares two args for a one-param Collection. + 'App\\Mid' => [new TypeRef(self::COLLECTION, [ + new TypeRef('X', isTypeParam: true), + new TypeRef('X', isTypeParam: true), + ])], + ], + typeParamNames: [ + self::IMMUTABLE_LIST => ['E'], + self::COLLECTION => ['E'], + 'App\\Mid' => ['X'], + ], + ); + } + + private function crossRegistry(): Registry + { + $hierarchy = $this->crossHierarchy(); + $registry = new Registry(Registry::DEFAULT_HASH_HEX_LENGTH, $hierarchy); + // Only the PARENT template's definition is read (for its slot variance); Collection<+E>. + $registry->recordDefinition( + self::COLLECTION, + 'Collection', + [new TypeParam('E', variance: Variance::Covariant)], + new Class_('Collection'), + 'test', + ); + return $registry; + } + + private function crossSubtyping(): VarianceSubtyping + { + return new VarianceSubtyping($this->crossHierarchy()); + } + + public function testCrossTemplateGenericArgRelatesThroughThreadedSupertype(): void + { + // The headline: a covariant outer slot holding `ImmutableList` vs `Collection`. + // isNestedSubtype's different-template branch threads ImmutableList → Collection, + // then compares Book ⊑ Product under Collection's covariant E → true. Kills the new branch's + // `isSubtype(...) === true` guard (a mutant dropping it would mis-handle this) and proves the + // threading produces the right arity. + self::assertTrue($this->crossSubtyping()->isVarianceSubtype( + [new TypeRef(self::IMMUTABLE_LIST, [new TypeRef(self::BOOK)])], + [new TypeRef(self::COLLECTION, [new TypeRef(self::PRODUCT)])], + self::covariant(), + $this->crossRegistry(), + )); + } + + public function testCrossTemplateUnrelatedTemplatesAreNotSubtype(): void + { + // Foo ⋢ Bar (no hierarchy edge): isSubtype short-circuits → no edge. A wrong "yes" here would + // emit a bogus `implements` → autoload fatal, so this is the expensive direction to keep false. + self::assertFalse($this->crossSubtyping()->isVarianceSubtype( + [new TypeRef('App\\Foo', [new TypeRef(self::BOOK)])], + [new TypeRef('App\\Bar', [new TypeRef(self::PRODUCT)])], + self::covariant(), + $this->crossRegistry(), + )); + } + + public function testCrossTemplateReverseDirectionIsNotSubtype(): void + { + // Operand order: the SUPER template in the subtype slot. Collection ⋢ ImmutableList, so + // isSubtype(Collection, ImmutableList) is false → no edge. Kills an operand-swap mutant on the + // `isSubtype($childName, $parentName)` call. + self::assertFalse($this->crossSubtyping()->isVarianceSubtype( + [new TypeRef(self::COLLECTION, [new TypeRef(self::BOOK)])], + [new TypeRef(self::IMMUTABLE_LIST, [new TypeRef(self::PRODUCT)])], + self::covariant(), + $this->crossRegistry(), + )); + } + + public function testContravariantSlotThreadsCrossTemplateArgInTheFlippedDirection(): void + { + // A contravariant outer slot flips the operands (isNestedSubtype($a2, $a1)): a subtype needs the + // SUPERTYPE-spec's arg to be a subtype of the SUBTYPE-spec's arg. With a cross-template inner + // pair, the branch must thread `ImmutableList` (the flipped child, from args2) up to + // `Collection` (args1) — proving the cross-template case composes symmetrically across + // variance, not just for the covariant direction. + self::assertTrue($this->crossSubtyping()->isVarianceSubtype( + [new TypeRef(self::COLLECTION, [new TypeRef(self::PRODUCT)])], + [new TypeRef(self::IMMUTABLE_LIST, [new TypeRef(self::BOOK)])], + [new TypeParam('X', variance: Variance::Contravariant)], + $this->crossRegistry(), + )); + } + + public function testContravariantSlotRejectsTheWrongCrossTemplateDirection(): void + { + // The flipped reject: with the args the other way round a contravariant slot needs + // `Collection ⊑ ImmutableList`, which is false (Collection ⋢ ImmutableList). + self::assertFalse($this->crossSubtyping()->isVarianceSubtype( + [new TypeRef(self::IMMUTABLE_LIST, [new TypeRef(self::BOOK)])], + [new TypeRef(self::COLLECTION, [new TypeRef(self::PRODUCT)])], + [new TypeParam('X', variance: Variance::Contravariant)], + $this->crossRegistry(), + )); + } + + public function testCrossTemplateMalformedGroundingIsNotSubtype(): void + { + // The load-bearing guard: `Mid implements Collection` grounds + // Mid to a NON-null but wrong-arity tuple [Book, Book] against the one-param Collection. + // resolveInheritedArgs returns it non-null; only isVarianceSubtype's count() arity guard rejects + // it. Without that guard a bogus edge would be emitted → autoload fatal. + self::assertFalse($this->crossSubtyping()->isVarianceSubtype( + [new TypeRef('App\\Mid', [new TypeRef(self::BOOK)])], + [new TypeRef(self::COLLECTION, [new TypeRef(self::PRODUCT)])], + self::covariant(), + $this->crossRegistry(), + )); + } } diff --git a/test/fixture/compile/cross_template_generic_arg_upcast/source/Book.xphp b/test/fixture/compile/cross_template_generic_arg_upcast/source/Book.xphp new file mode 100644 index 0000000..d120302 --- /dev/null +++ b/test/fixture/compile/cross_template_generic_arg_upcast/source/Book.xphp @@ -0,0 +1,7 @@ + +{ + public function first(): ?E; +} diff --git a/test/fixture/compile/cross_template_generic_arg_upcast/source/Couple.xphp b/test/fixture/compile/cross_template_generic_arg_upcast/source/Couple.xphp new file mode 100644 index 0000000..ee55a0f --- /dev/null +++ b/test/fixture/compile/cross_template_generic_arg_upcast/source/Couple.xphp @@ -0,0 +1,20 @@ + implements Tuple +{ + public function __construct(private A $a, private B $b) {} + + public function left(): A + { + return $this->a; + } + + public function right(): B + { + return $this->b; + } +} diff --git a/test/fixture/compile/cross_template_generic_arg_upcast/source/ImmutableList.xphp b/test/fixture/compile/cross_template_generic_arg_upcast/source/ImmutableList.xphp new file mode 100644 index 0000000..8cb0712 --- /dev/null +++ b/test/fixture/compile/cross_template_generic_arg_upcast/source/ImmutableList.xphp @@ -0,0 +1,21 @@ + implements Collection +{ + /** @var list */ + private array $items; + + public function __construct(E ...$items) + { + $this->items = $items; + } + + public function first(): ?E + { + return $this->items[0] ?? null; + } +} diff --git a/test/fixture/compile/cross_template_generic_arg_upcast/source/Product.xphp b/test/fixture/compile/cross_template_generic_arg_upcast/source/Product.xphp new file mode 100644 index 0000000..f94c89a --- /dev/null +++ b/test/fixture/compile/cross_template_generic_arg_upcast/source/Product.xphp @@ -0,0 +1,7 @@ + +{ + public function left(): A; + + public function right(): B; +} diff --git a/test/fixture/compile/cross_template_generic_arg_upcast/source/Use.xphp b/test/fixture/compile/cross_template_generic_arg_upcast/source/Use.xphp new file mode 100644 index 0000000..58c765b --- /dev/null +++ b/test/fixture/compile/cross_template_generic_arg_upcast/source/Use.xphp @@ -0,0 +1,20 @@ +, Tag>`, but is +// called with a `Couple, Tag>`. For that to load and run, the covariant edge +// Tuple, Tag> ⊑ Tuple, Tag> +// must be emitted — which requires recognizing the ARGUMENT subtype `ImmutableList ⊑ +// Collection` across DIFFERENT templates (`ImmutableList implements Collection`, both +// covariant). It reads the left element list through the interface and pulls its first element. +function useTuple(Tuple, Tag> $t): ?Product +{ + return $t->left()->first(); +} + +$books = new ImmutableList::(new Book()); +$couple = new Couple::, Tag>($books, new Tag()); +$first = useTuple($couple); diff --git a/test/fixture/compile/cross_template_generic_arg_upcast/verify/runtime.php b/test/fixture/compile/cross_template_generic_arg_upcast/verify/runtime.php new file mode 100644 index 0000000..ce57339 --- /dev/null +++ b/test/fixture/compile/cross_template_generic_arg_upcast/verify/runtime.php @@ -0,0 +1,27 @@ +, Tag>` is upcast to `Tuple, Tag>` and used through + * the interface. For the program to LOAD, the covariant edge + * Tuple, Tag> ⊑ Tuple, Tag> + * must have been emitted — and that requires the per-argument subtype `ImmutableList ⊑ + * Collection` to be recognized across DIFFERENT templates. Before the fix the edge is + * silently omitted, `xphp check` passes, and this `require` fatals with a `TypeError` (the Couple + * specialization never implements the `Tuple, Tag>` marker). + * + * That the program loads, the call resolves, and `first()` returns the Book proves the cross-template + * edge was emitted and the covariance holds at runtime — not just at `check`. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertInstanceOf(\App\Book::class, $first, 'the upcast tuple resolved and yielded its Book element'); +echo "OK\n"; From b02bd5db665a4a3d7e73e5165a75a76e9109090c Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 26 Jun 2026 10:41:52 +0000 Subject: [PATCH 110/114] docs: document variance composing through a nested generic type-argument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record that a covariant slot whose argument is itself a generic of a different but related template now emits its variance edge — the variance guide gains a "nested type-arguments" subsection (a covariant Tuple holding a covariant container relates by the container's element type, proven by threading the argument up its implements/extends chain), and the changelog notes the covariance now holds at runtime rather than passing check and fataling at load. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 11 +++++++++++ docs/syntax/variance.md | 29 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 225f3b4..fa811c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,17 @@ _In progress on this branch — content still accumulating; date set at tag time by-reference slot is both read and written through the caller's binding, so it is invariant — the same rule already applied to a mutable property. See [variance](docs/syntax/variance.md). +- **Variance composes through a nested generic type-argument.** A covariant slot whose + argument is itself a generic of a *different but related* template now emits its + `extends`/`implements` edge — so a covariant `Tuple<+A, +B>` holding a covariant + container relates by that container's element type (`Tuple, Tag>` + is usable where a `Tuple, Tag>` is required, because + `ImmutableList ⊑ Collection`). The argument relationship is proven by + threading the subtype's element up its `implements`/`extends` chain to the supertype's + template and comparing under the inner template's variance; the edge is emitted only + when positively provable, so the covariance now holds at runtime (`instanceof`, type + hints) and not only at `check`. Previously such an upcast passed `check` but fatal'd at + load. See [variance](docs/syntax/variance.md). ### Changed diff --git a/docs/syntax/variance.md b/docs/syntax/variance.md index 5baee7f..f68f85c 100644 --- a/docs/syntax/variance.md +++ b/docs/syntax/variance.md @@ -70,6 +70,35 @@ including reflection and `instanceof`. For contravariant `-T`, the edge flips: `Consumer extends Consumer`. +### Nested type-arguments (a generic as a type-argument) + +A covariant slot can hold another generic as its argument, and the edge +composes through it — including when the inner argument is a generic of a +**different but related template**. A covariant `Tuple<+A, +B>` holding a +covariant container relates by *its* element relationship: + +```php +interface Collection<+E> { /* … */ } +class ImmutableList<+E> implements Collection { /* … */ } +interface Tuple<+A, +B> { /* … */ } +class Couple<+A, +B> implements Tuple { /* … */ } + +// Book <: Product, ImmutableList implements Collection, all covariant ⇒ +// Tuple, Tag> ⊑ Tuple, Tag> +``` + +The compiler proves the argument relationship `ImmutableList ⊑ +Collection` by threading the subtype's element up its +`implements`/`extends` chain to the supertype's template +(`ImmutableList` → `Collection`) and comparing under the inner +template's variance (`Book ⊑ Product` under `Collection`'s covariant `E`), +then emits the `Tuple` edge. So a `Couple, Tag>` is +usable where a `Tuple, Tag>` is required, with the +covariance honoured at runtime (`instanceof`, type hints), not only at +`check`. The edge is emitted only when the relationship is *positively* +provable — an unrelated or unprovable inner pair stays conservative (no +edge), never a bogus one that would PHP-fatal at autoload. + ### Unprovable variance edges An `extends` edge only emits when the compiler can **prove** the element relationship From e67b5244471e9b9507019f9bb675c84399c3437d Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 26 Jun 2026 11:15:51 +0000 Subject: [PATCH 111/114] fix(monomorphize): compose variance through type-constructor args via the inner pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A method parameter typed `Comparator` on a covariant `+E` class was wrongly rejected (`xphp.variance_position`) though it is sound: `Comparator` is contravariant, so `E` sits in a contravariant slot inside a contravariant parameter position — contra ∘ contra = covariant, which a covariant `+E` may occupy. The position validator judged the nested `E` by the direct parameter position without composing the referenced type's slot variance, and because it flagged the template the composing inner-variance pass was skipped for it, so the wrong verdict was final. Reconcile the two passes into disjoint responsibilities: the position validator now checks only DIRECT occurrences of a variant type-param (a bare `E` as a parameter/return/property/bound/default), on both its descent paths; the composing inner-variance pass owns every type-constructor-nested occurrence and reports only nested leaves, with one exception — a non-bare direct type-param in a constructor parameter, which the position pass exempts entirely, stays owned by the composing pass. The skip handoff is removed, so a template carrying both a direct and a nested violation now reports each exactly once instead of dropping the nested one. The composing pass already produced the correct verdicts, so the genuinely unsound shapes stay rejected: a bare `+E` parameter, a `Producer` parameter on `+E` (compose to contravariant), and the mirror `Sink<-E>` with a `Comparator` parameter (compose to covariant). A runtime fixture proves the now-accepted `Comparator`-on-`+E` shape loads and runs soundly under a covariant upcast. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/Compiler.php | 8 +- .../Monomorphize/InnerVarianceValidator.php | 25 +++- src/Transpiler/Monomorphize/Registry.php | 13 +- .../VariancePositionValidator.php | 40 ++---- .../RegistryInnerVarianceTest.php | 36 +++--- .../VarianceEdgeIntegrationTest.php | 21 ++++ .../VariancePositionPhaseTest.php | 117 ++++++++++++++++-- .../source/Book.xphp | 7 ++ .../source/Box.xphp | 30 +++++ .../source/ById.xphp | 15 +++ .../source/Comparator.xphp | 10 ++ .../source/Product.xphp | 10 ++ .../source/Use.xphp | 17 +++ .../verify/runtime.php | 27 ++++ 14 files changed, 303 insertions(+), 73 deletions(-) create mode 100644 test/fixture/compile/comparator_param_covariant_upcast/source/Book.xphp create mode 100644 test/fixture/compile/comparator_param_covariant_upcast/source/Box.xphp create mode 100644 test/fixture/compile/comparator_param_covariant_upcast/source/ById.xphp create mode 100644 test/fixture/compile/comparator_param_covariant_upcast/source/Comparator.xphp create mode 100644 test/fixture/compile/comparator_param_covariant_upcast/source/Product.xphp create mode 100644 test/fixture/compile/comparator_param_covariant_upcast/source/Use.xphp create mode 100644 test/fixture/compile/comparator_param_covariant_upcast/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 25fc2d0..c69e200 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -107,7 +107,7 @@ public function compile( // collect across files; compile-mode still throws on the first violation. // Runs BEFORE the defaults-vs-bounds check so that, when a class has both, // the variance error surfaces first (the order it surfaced at parse time). - $variancePositionFlagged = $registry->validateVariancePositions(); + $registry->validateVariancePositions(); // Undeclared type names in member signatures (a stray/typo'd type param) // fail before defaults/instantiation so a non-existent reference never // reaches emission as broken PHP. @@ -118,7 +118,7 @@ public function compile( // couldn't catch (e.g. `class P<+T> { f(): Container }` where // Container's slot is invariant) fail here BEFORE instantiations // amplify the error. - $registry->validateInnerVariance($variancePositionFlagged); + $registry->validateInnerVariance(); foreach ($astPerFile as $filepath => $ast) { $collector->collectInstantiations($ast, $filepath); } @@ -316,11 +316,11 @@ public function check(FilepathArray $sources): DiagnosticCollector foreach ($astPerFile as $filepath => $ast) { $collector->collectDefinitions($ast, $filepath); } - $variancePositionFlagged = $registry->validateVariancePositions(); + $registry->validateVariancePositions(); $registry->validateUndeclaredTypeParameters(); UndeclaredTypeParameterValidator::assertMethodLevel($astPerFile, $hierarchy, $diagnostics); $registry->validateDefaultsAgainstBounds(); - $registry->validateInnerVariance($variancePositionFlagged); + $registry->validateInnerVariance(); foreach ($astPerFile as $filepath => $ast) { $collector->collectInstantiations($ast, $filepath); } diff --git a/src/Transpiler/Monomorphize/InnerVarianceValidator.php b/src/Transpiler/Monomorphize/InnerVarianceValidator.php index e5b3bab..073ba97 100644 --- a/src/Transpiler/Monomorphize/InnerVarianceValidator.php +++ b/src/Transpiler/Monomorphize/InnerVarianceValidator.php @@ -145,7 +145,11 @@ private function collect(GenericDefinition $definition): void ? Variance::Invariant : Variance::Contravariant; if ($param->type !== null) { - $this->walkPhpType($param->type, $outerPos, $label, null, null); + // Constructor params are exempt from the position pass entirely, so this pass keeps + // ownership of a non-bare DIRECT type-param there (`?T`); the bare-`T` immutable + // shape was already exempted above. Every other position cedes its direct leaves to + // the position pass (reportDirect = false). + $this->walkPhpType($param->type, $outerPos, $label, null, null, reportDirect: $isConstructor); } } if ($method->returnType !== null) { @@ -209,6 +213,7 @@ private function walkPhpType( string $outerLabel, ?string $innerLabel, ?int $innerSlot, + bool $reportDirect = false, ): void { if ($type instanceof Identifier) { return; @@ -216,7 +221,7 @@ private function walkPhpType( if ($type instanceof Name) { $parts = $type->getParts(); if (count($parts) === 1 && isset($this->varianceMap[$parts[0]])) { - $this->assertLeaf($parts[0], $this->varianceMap[$parts[0]], $position, $outerLabel, $innerLabel, $innerSlot, $type->getStartLine()); + $this->assertLeaf($parts[0], $this->varianceMap[$parts[0]], $position, $outerLabel, $innerLabel, $innerSlot, $type->getStartLine(), $reportDirect); } $args = $type->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); if (is_array($args)) { @@ -236,12 +241,12 @@ private function walkPhpType( return; } if ($type instanceof NullableType) { - $this->walkPhpType($type->type, $position, $outerLabel, $innerLabel, $innerSlot); + $this->walkPhpType($type->type, $position, $outerLabel, $innerLabel, $innerSlot, $reportDirect); return; } if ($type instanceof UnionType || $type instanceof IntersectionType) { foreach ($type->types as $sub) { - $this->walkPhpType($sub, $position, $outerLabel, $innerLabel, $innerSlot); + $this->walkPhpType($sub, $position, $outerLabel, $innerLabel, $innerSlot, $reportDirect); } return; } @@ -309,7 +314,19 @@ private function assertLeaf( ?string $innerLabel, ?int $innerSlot, ?int $line, + bool $reportDirect = false, ): void { + // This composing pass reports only NESTED leaves — a type-param inside a type constructor's + // arguments (`innerSlot !== null`), where the effective variance is the composition of the + // outer position with the inner slot. A DIRECT occurrence (a bare type-param as the + // param/return/property/bound/default type, `innerSlot === null`) is owned by + // VariancePositionValidator; reporting it here too would double-report it. The ONE exception is + // a non-bare *constructor* parameter (`?T`, where the bare-`T` immutable-construction shape is + // already exempted before the walk): the position pass exempts constructor params entirely, so + // this pass keeps ownership of that direct case via `$reportDirect`. + if ($innerSlot === null && !$reportDirect) { + return; + } $allowed = match ($effective) { Variance::Invariant => [Variance::Invariant], Variance::Covariant => [Variance::Invariant, Variance::Covariant], diff --git a/src/Transpiler/Monomorphize/Registry.php b/src/Transpiler/Monomorphize/Registry.php index c402a89..cc0ec2f 100644 --- a/src/Transpiler/Monomorphize/Registry.php +++ b/src/Transpiler/Monomorphize/Registry.php @@ -479,15 +479,14 @@ public function validateUndeclaredTypeParameters(): void * {@see InnerVarianceValidator}). With this Registry's collector it gathers every violation * (each located at the offending member); without one (compile) it throws the first. * - * @param list $skipTemplateFqns Definitions already flagged by the position check; - * skipped here so the same `+T`/`-T` misuse isn't reported by both passes. + * Runs on every definition: this composing pass and {@see validateVariancePositions} own disjoint + * responsibilities — the position pass reports DIRECT occurrences of a variant type-param, this one + * the type-constructor-NESTED occurrences (composing the inner slot's variance) — so there is no + * double-reporting and no template to skip. */ - public function validateInnerVariance(array $skipTemplateFqns = []): void + public function validateInnerVariance(): void { - foreach ($this->definitions as $templateFqn => $definition) { - if (in_array($templateFqn, $skipTemplateFqns, true)) { - continue; - } + foreach ($this->definitions as $definition) { InnerVarianceValidator::assertComposition( $definition, $this->definitions, diff --git a/src/Transpiler/Monomorphize/VariancePositionValidator.php b/src/Transpiler/Monomorphize/VariancePositionValidator.php index faa9447..3e9b993 100644 --- a/src/Transpiler/Monomorphize/VariancePositionValidator.php +++ b/src/Transpiler/Monomorphize/VariancePositionValidator.php @@ -203,15 +203,16 @@ private function checkBoundExpr(BoundExpr $bound, string $hostParam, string $hos private function checkTypeRef(TypeRef $ref, string $hostParam, string $hostPosition, int $line): void { + // Direct occurrence only: a bare type-param as a bound/default (`U : T`, `U = T`). A type-param + // NESTED inside a type constructor in a bound/default (`U : Box`) is the composing pass's job + // (its effective variance depends on the inner slot) — descending here with the uncomposed + // position would mis-judge it and double-report against InnerVarianceValidator. if ($ref->isTypeParam && isset($this->varianceByName[$ref->name])) { $this->record( self::violationMessage($ref->name, $this->varianceByName[$ref->name], $hostPosition, $hostParam), $line, ); } - foreach ($ref->args as $inner) { - $this->checkTypeRef($inner, $hostParam, $hostPosition, $line); - } } private function checkProperty(Property $property): void @@ -383,17 +384,12 @@ private function checkPhpType(Node $type, array $allowed, string $position): voi } } } - // Generic args attached via xphp:genericArgs are TypeRef trees; - // recurse into them so `Box` in a parameter position is - // checked too. - $args = $type->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); - if (is_array($args)) { - foreach ($args as $arg) { - if ($arg instanceof TypeRef) { - $this->checkInnerTypeRef($arg, $allowed, $position, $type->getStartLine()); - } - } - } + // A type-param NESTED inside a type constructor (`Box`, `Comparator`) is NOT judged + // here: its effective variance is the composition of this position with the referenced + // type's slot variance, which only InnerVarianceValidator resolves. Descending with this + // (uncomposed) position would wrongly reject a sound `Comparator` on a covariant `+E` + // (contra ∘ contra = covariant) and wrongly pass an unsound one. This validator owns only + // DIRECT occurrences; the composing pass owns the nested ones. return; } if ($type instanceof NullableType) { @@ -411,22 +407,6 @@ private function checkPhpType(Node $type, array $allowed, string $position): voi } } - /** - * @param list $allowed - */ - private function checkInnerTypeRef(TypeRef $ref, array $allowed, string $position, int $line): void - { - if ($ref->isTypeParam && isset($this->varianceByName[$ref->name])) { - $variance = $this->varianceByName[$ref->name]; - if (!in_array($variance, $allowed, true)) { - $this->record(self::violationMessage($ref->name, $variance, $position, null), $line); - } - } - foreach ($ref->args as $inner) { - $this->checkInnerTypeRef($inner, $allowed, $position, $line); - } - } - private function record(string $message, ?int $line): void { $this->violations[] = ['message' => $message, 'line' => $line]; diff --git a/test/Transpiler/Monomorphize/RegistryInnerVarianceTest.php b/test/Transpiler/Monomorphize/RegistryInnerVarianceTest.php index d105276..18c2f74 100644 --- a/test/Transpiler/Monomorphize/RegistryInnerVarianceTest.php +++ b/test/Transpiler/Monomorphize/RegistryInnerVarianceTest.php @@ -32,11 +32,12 @@ */ final class RegistryInnerVarianceTest extends TestCase { - public function testPositionFlaggedDefinitionIsSkippedButLaterDefinitionsStillRun(): void + public function testDirectAndNestedViolationsAreEachReportedExactlyOnce(): void { - // P (direct +T-in-param) is flagged by the position check and recorded FIRST; - // Q (composition violation) is recorded AFTER. Inner-variance must skip P (already - // reported) yet still report Q — i.e. it must `continue` past P, not `break`. + // P (direct +T-in-param) is owned by the position pass; Q (a nested composition violation) is + // owned by the composing inner pass. With disjoint responsibilities (no skip handoff), the two + // passes report exactly one diagnostic each — P's `variance_position` and Q's `inner_variance`, + // with no double-report of P by the inner pass. $collector = new DiagnosticCollector(); $registry = $this->registryWith([ $this->makeDefinition( @@ -55,7 +56,7 @@ public function testPositionFlaggedDefinitionIsSkippedButLaterDefinitionsStillRu ], $collector); $flagged = $registry->validateVariancePositions(); - $registry->validateInnerVariance($flagged); + $registry->validateInnerVariance(); self::assertSame(['App\\P'], $flagged); self::assertCount(2, $collector->all()); @@ -64,10 +65,11 @@ public function testPositionFlaggedDefinitionIsSkippedButLaterDefinitionsStillRu self::assertContains(InnerVarianceValidator::CODE_INNER_VARIANCE, $codes); } - public function testAllPositionFlaggedDefinitionsAreSkippedByInnerVariance(): void + public function testDirectViolationsAreNotDoubleReportedByTheComposingPass(): void { - // Two direct +T-in-param violations: both flagged by the position check, so the - // inner-variance pass must skip BOTH (the full flagged list, not a truncation). + // Two direct +T-in-param violations: both owned by the position pass. The composing inner pass + // reports only NESTED occurrences, so it adds nothing here — exactly two diagnostics total, both + // `variance_position`, with no double-report. $collector = new DiagnosticCollector(); $registry = $this->registryWith([ $this->makeDefinition( @@ -85,7 +87,7 @@ public function testAllPositionFlaggedDefinitionsAreSkippedByInnerVariance(): vo ], $collector); $flagged = $registry->validateVariancePositions(); - $registry->validateInnerVariance($flagged); + $registry->validateInnerVariance(); self::assertSame(['App\\P', 'App\\R'], $flagged); self::assertCount(2, $collector->all()); @@ -999,12 +1001,12 @@ public function testStaticMethodReturnTypeIsWalked(): void $registry->validateInnerVariance(); } - public function testPropertyInvariantPositionRejectsCovariantOuter(): void + public function testDirectPropertyCovariantOuterIsOwnedByThePositionPass(): void { - // class P<+T> { public T $item; } - // Property slot is Invariant for outer T directly (not even via inner). - // The parse-time validator catches this; verify the new pass doesn't - // double-throw (its error path uses a different framing). + // class P<+T> { public T $item; } — a DIRECT covariant type-param in an invariant + // (visible-property) position. This is a direct occurrence, owned by the position pass; the + // composing inner pass reports only type-constructor-NESTED occurrences, so it stays SILENT here + // (no double-report). The position pass is the one that rejects it. $registry = $this->registryWith([ $this->makeDefinition( 'App\\P', @@ -1014,9 +1016,11 @@ public function testPropertyInvariantPositionRejectsCovariantOuter(): void ), ]); + $registry->validateInnerVariance(); // silent on a direct occurrence — must not throw + $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('invariant-only position'); - $registry->validateInnerVariance(); + $this->expectExceptionMessage('mutable property'); + $registry->validateVariancePositions(); } // ----- helpers --------------------------------------------------------- diff --git a/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php b/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php index 324b2fd..e887b14 100644 --- a/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php +++ b/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php @@ -187,6 +187,27 @@ public function testCrossTemplateGenericArgUpcastEmitsEdgeAndRunsAtRuntime(): vo } } + #[RunInSeparateProcess] + public function testComparatorParamOnCovariantClassCompilesAndRunsUnderUpcast(): void + { + // A covariant `Box<+E>` with a `pick(Comparator $c): ?E` consuming method — the sound shape + // where `E` sits in a contravariant slot (Comparator<-T>) inside a contravariant parameter + // (contra ∘ contra = covariant). Previously rejected `xphp.variance_position` even though sound; + // the composing variance pass now accepts it. A `Box` is upcast to `Box` and + // `pick` is called with a `ById` (a Comparator, hence by contravariance a + // Comparator): it loads, runs, and returns the max Book — proving the acceptance is sound. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/comparator_param_covariant_upcast/source', + 'comparator-param-covariant', + ); + try { + $fixture->registerAutoload('App\\'); + require __DIR__ . '/../../fixture/compile/comparator_param_covariant_upcast/verify/runtime.php'; + } finally { + $fixture->cleanup(); + } + } + public function testBoundedCovariantConstructorKeepsConcreteType(): void { // A bounded covariant constructor param keeps its REAL substituted type (the diff --git a/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php b/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php index 7c7f3db..bb919d6 100644 --- a/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php +++ b/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php @@ -40,10 +40,9 @@ public static function rejectedSources(): iterable "\n{\n public readonly T \$item;\n public function get(): T { return \$this->item; }\n}\n", ['readonly property'], ]; - yield 'covariant in bound' => [ - ">\n{\n public function get(): T { throw new \\LogicException; }\n}\n", - ['bound'], - ]; + // NOTE: a NESTED type-param in a bound (`+T : Box`) is owned by the composing + // inner-variance pass, not this direct-position pass — see + // testNestedTypeParamIsRejectedByTheComposingPass. // A covariant param as the BARE leaf of a sibling class param's bound is a bound position // too, flagged consistently with the inner-arg `Box` case above. (Distinct from the // supported method-level `contains` shape, where U is a *method* type parameter.) @@ -94,14 +93,8 @@ public static function rejectedSources(): iterable "\n{\n public function pipe(): array\n {\n \$f = fn (): T => null;\n return [];\n }\n}\n", ['nested closure/arrow return'], ]; - yield 'covariant in nested generic input' => [ - "\n{\n public function set(Box \$x): void {}\n}\n", - ['+T', 'method parameter'], - ]; - yield 'contravariant in nested generic return' => [ - "\n{\n public function fetch(): Box { throw new \\LogicException; }\n}\n", - ['-T', 'method return'], - ]; + // NOTE: a NESTED type-param in a method parameter/return (`Box`) is owned by the + // composing inner-variance pass — see testNestedTypeParamIsRejectedByTheComposingPass. yield 'interface method signature' => [ "\n{\n public function feed(T \$x): void;\n}\n", ['+T'], @@ -182,6 +175,106 @@ public function testVariancePositionIsRejectedInCompileMode(string $source, arra } } + /** + * A type-param NESTED inside a type constructor (`Box` in a parameter, return, or bound) is + * judged by the COMPOSING inner-variance pass, not the direct-position pass: its effective variance + * is the composition of the outer position with the inner slot's variance (here Box's invariant + * slot ⇒ invariant ⇒ `+T`/`-T` rejected). The direct-position pass deliberately does NOT descend + * into type-constructor args, so these are reported by `validateInnerVariance` with the composing + * "via slot N of …" message — never double-reported by both passes. + * + * @param list $fragments + */ + #[DataProvider('nestedComposingRejections')] + public function testNestedTypeParamIsRejectedByTheComposingPass(string $source, array $fragments): void + { + $registry = $this->registryFor($source); + // The direct-position pass must stay SILENT on a purely-nested violation (no double-report). + $registry->validateVariancePositions(); + + try { + $registry->validateInnerVariance(); + self::fail('expected an inner-variance composition violation'); + } catch (RuntimeException $e) { + foreach ($fragments as $fragment) { + self::assertStringContainsString($fragment, $e->getMessage()); + } + } + } + + /** + * @return iterable}> + */ + public static function nestedComposingRejections(): iterable + { + yield 'covariant nested in method parameter' => [ + "\n{\n public function set(Box \$x): void {}\n}\n", + ['+T', 'invariant-only position', 'via slot 0 of'], + ]; + yield 'contravariant nested in method return' => [ + "\n{\n public function fetch(): Box { throw new \\LogicException; }\n}\n", + ['-T', 'invariant-only position', 'via slot 0 of'], + ]; + yield 'covariant nested in bound' => [ + ">\n{\n public function get(): T { throw new \\LogicException; }\n}\n", + ['+T', 'invariant-only position', 'via slot 0 of'], + ]; + // A NESTED type-param in a VISIBLE (non-promoted) declared property — `public Box $item` on + // `+T`. The property's outer position is invariant; Box's invariant slot composes to invariant, + // so `+T` is rejected by the composing pass via the declared-property walk (a private property + // would be exempt — only visible ones are walked). + yield 'covariant nested in visible declared property' => [ + "\n{\n public Box \$item;\n}\n", + ['+T', 'invariant-only position', 'via slot 0 of'], + ]; + // Composition through a NON-invariant inner slot. `Producer<+X>` as a method PARAMETER: + // compose(contravariant param, covariant slot) = contravariant → a covariant `+E` is rejected. + yield 'covariant Producer param composes to contravariant' => [ + " { public function get(): X; }\nclass Box<+E>\n{\n public function take(Producer \$p): void {}\n}\n", + ['+E', 'contravariant-only position', 'via slot 0 of'], + ]; + // The mirror that confirms the composing pass owns the unsound direction too: `Sink<-E>` with a + // `Comparator<-T>` parameter — compose(contravariant param, contravariant slot) = covariant → a + // contravariant `-E` is rejected. (The sound covariant case is the accept test below.) + yield 'contravariant Sink with Comparator composes to covariant' => [ + " { public function compare(T \$a, T \$b): int; }\nclass Sink<-E>\n{\n public function pick(Comparator \$c): void {}\n}\n", + ['-E', 'covariant-only position', 'via slot 0 of'], + ]; + } + + /** + * The sound nested compositions that the buggy direct-position descent used to reject: a type-param + * nested in a contravariant slot inside a same-variance outer position composes back to that + * variance, which the class's marker may occupy. Neither pass may flag these. + * + * @param string $source + */ + #[DataProvider('soundNestedCompositions')] + public function testSoundNestedCompositionIsAccepted(string $source): void + { + $registry = $this->registryFor($source); + $registry->validateVariancePositions(); // must NOT throw + $registry->validateInnerVariance(); // must NOT throw + $this->addToAssertionCount(1); + } + + /** + * @return iterable + */ + public static function soundNestedCompositions(): iterable + { + // The headline sound case: a `Comparator<-T>` parameter on a covariant `+E` — compose(contra + // param, contra slot) = covariant, which `+E` may occupy. Sound; must be accepted. + yield 'Comparator param on covariant class' => [ + " { public function compare(T \$a, T \$b): int; }\nclass Box<+E>\n{\n public function pick(Comparator \$c): void {}\n}\n", + ]; + // A `Producer<+X>` RETURN on a covariant `+E` — compose(covariant return, covariant slot) = + // covariant. Sound; must be accepted. + yield 'Producer return on covariant class' => [ + " { public function get(): X; }\nclass Box<+E>\n{\n public function make(): Producer { throw new \\LogicException; }\n}\n", + ]; + } + /** * A private property carrying a variance marker must pass BOTH the position * check and the inner-variance composition check (the latter for the diff --git a/test/fixture/compile/comparator_param_covariant_upcast/source/Book.xphp b/test/fixture/compile/comparator_param_covariant_upcast/source/Book.xphp new file mode 100644 index 0000000..d120302 --- /dev/null +++ b/test/fixture/compile/comparator_param_covariant_upcast/source/Book.xphp @@ -0,0 +1,7 @@ +` CONSUMING method — the sound shape where `E` sits in a +// contravariant slot (Comparator<-T>) inside a contravariant parameter position, so +// contra ∘ contra = covariant, which a covariant `+E` may occupy. +class Box<+E> +{ + /** @var list */ + private array $items; + + public function __construct(E ...$items) + { + $this->items = $items; + } + + public function pick(Comparator $c): ?E + { + $best = $this->items[0] ?? null; + foreach ($this->items as $item) { + if ($best !== null && $c->compare($item, $best) > 0) { + $best = $item; + } + } + return $best; + } +} diff --git a/test/fixture/compile/comparator_param_covariant_upcast/source/ById.xphp b/test/fixture/compile/comparator_param_covariant_upcast/source/ById.xphp new file mode 100644 index 0000000..01ff20b --- /dev/null +++ b/test/fixture/compile/comparator_param_covariant_upcast/source/ById.xphp @@ -0,0 +1,15 @@ +. By contravariance (Comparator<-T>), a Comparator is also a +// Comparator, so it can compare the Book elements of a Box upcast to Box. +class ById implements Comparator +{ + public function compare(Product $a, Product $b): int + { + return $a->id <=> $b->id; + } +} diff --git a/test/fixture/compile/comparator_param_covariant_upcast/source/Comparator.xphp b/test/fixture/compile/comparator_param_covariant_upcast/source/Comparator.xphp new file mode 100644 index 0000000..46c34bb --- /dev/null +++ b/test/fixture/compile/comparator_param_covariant_upcast/source/Comparator.xphp @@ -0,0 +1,10 @@ + +{ + public function compare(T $a, T $b): int; +} diff --git a/test/fixture/compile/comparator_param_covariant_upcast/source/Product.xphp b/test/fixture/compile/comparator_param_covariant_upcast/source/Product.xphp new file mode 100644 index 0000000..e8f9d87 --- /dev/null +++ b/test/fixture/compile/comparator_param_covariant_upcast/source/Product.xphp @@ -0,0 +1,10 @@ +` and a `Comparator`, but is called with a `Box` and a `ById` +// (a Comparator, hence by contravariance a Comparator). The Box's `pick` +// compares its Book elements through the Product comparator — sound under the covariant upcast. +function choose(Box $b, Comparator $c): ?Product +{ + return $b->pick($c); +} + +$books = new Box::(new Book(1), new Book(3), new Book(2)); +$best = choose($books, new ById()); diff --git a/test/fixture/compile/comparator_param_covariant_upcast/verify/runtime.php b/test/fixture/compile/comparator_param_covariant_upcast/verify/runtime.php new file mode 100644 index 0000000..4141f30 --- /dev/null +++ b/test/fixture/compile/comparator_param_covariant_upcast/verify/runtime.php @@ -0,0 +1,27 @@ +` is upcast to `Box` and its `pick(Comparator $c)` is called with a + * `ById` (a `Comparator`, hence by contravariance a `Comparator`). For the program to + * LOAD and run, the covariant `Box ⊑ Box` edge and the contravariant + * `Comparator ⊑ Comparator` edge must both be emitted, and `Box::pick` must accept + * the comparator (its parameter widens to `Comparator` — sound contravariant param widening). + * + * The whole shape was previously REJECTED at compile time (`xphp.variance_position`) even though it is + * sound; the fix routes the nested `Comparator` verdict through the composing variance pass, which + * accepts it. That the program runs and `pick` returns the max Book proves the acceptance is sound. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + */ + +use PHPUnit\Framework\Assert; + +require $fixture->targetDir . '/Use.php'; + +Assert::assertInstanceOf(\App\Book::class, $best, 'pick returned a Book element through the upcast'); +Assert::assertSame(3, $best->id, 'pick selected the max-id Book via the Product comparator'); +echo "OK\n"; From b41497065bd4a6b56c23c954e7a87f4f6c1456db Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 26 Jun 2026 18:06:21 +0000 Subject: [PATCH 112/114] fix(monomorphize): don't double-report a visible promoted constructor property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composing variance pass kept ownership of the direct leaf of every constructor parameter, to cover the non-bare shapes (`?T`) the position pass exempts. But a VISIBLE promoted constructor property (`public T $item`) is NOT exempt from the position pass — it reports it as a constructor-parameter violation — so both passes flagged it: in check-mode the same property produced two diagnostics for one source location. Cede the direct leaf to the position pass for a promoted constructor property (non-zero param flags); keep composing-pass ownership only for a non-promoted constructor param, which the position pass genuinely exempts. Nested leaves in a promoted property (`public Box $item`) stay owned by the composing pass. Compile-mode is unaffected (the position pass throws first); this only removed the duplicate check-mode diagnostic. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/InnerVarianceValidator.php | 14 +++++--- .../VariancePositionPhaseTest.php | 35 +++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/Transpiler/Monomorphize/InnerVarianceValidator.php b/src/Transpiler/Monomorphize/InnerVarianceValidator.php index 073ba97..69e430e 100644 --- a/src/Transpiler/Monomorphize/InnerVarianceValidator.php +++ b/src/Transpiler/Monomorphize/InnerVarianceValidator.php @@ -145,11 +145,15 @@ private function collect(GenericDefinition $definition): void ? Variance::Invariant : Variance::Contravariant; if ($param->type !== null) { - // Constructor params are exempt from the position pass entirely, so this pass keeps - // ownership of a non-bare DIRECT type-param there (`?T`); the bare-`T` immutable - // shape was already exempted above. Every other position cedes its direct leaves to - // the position pass (reportDirect = false). - $this->walkPhpType($param->type, $outerPos, $label, null, null, reportDirect: $isConstructor); + // A NON-PROMOTED constructor param is exempt from the position pass entirely, so + // this pass keeps ownership of a non-bare DIRECT type-param there (`?T`); the bare-`T` + // immutable shape was already exempted above. A PROMOTED constructor property + // (`public T $item`) is NOT exempt from the position pass (it reports it as a + // 'constructor parameter'), so this pass must cede its direct leaf to avoid a + // double-report — only its NESTED leaves stay here. Every non-constructor position + // cedes its direct leaves too (reportDirect = false). + $reportDirect = $isConstructor && $param->flags === 0; + $this->walkPhpType($param->type, $outerPos, $label, null, null, reportDirect: $reportDirect); } } if ($method->returnType !== null) { diff --git a/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php b/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php index bb919d6..38a249f 100644 --- a/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php +++ b/test/Transpiler/Monomorphize/VariancePositionPhaseTest.php @@ -309,6 +309,41 @@ public function testPrivatePromotedDoesNotShortCircuitLaterConstructorParam(): v $registry->validateInnerVariance(); } + public function testVisiblePromotedConstructorPropertyIsReportedExactlyOnce(): void + { + // A VISIBLE promoted constructor property (`public T $item`) is a direct occurrence the + // position pass owns (it reports it as a 'constructor parameter'). The composing pass must NOT + // also report it — it cedes the direct leaf of a PROMOTED constructor param (only a NON-promoted + // constructor param, which the position pass exempts, stays owned by the composing pass). In + // check-mode (both passes run) this must yield EXACTLY ONE diagnostic, not two. + $source = "\n{\n public function __construct(public T \$item) {}\n}\n"; + $collector = new DiagnosticCollector(); + $registry = $this->registryFor($source, $collector); + + $registry->validateVariancePositions(); + $registry->validateInnerVariance(); + + self::assertCount(1, $collector->all()); + self::assertSame(VariancePositionValidator::CODE_VARIANCE_POSITION, $collector->all()[0]->code); + } + + public function testNonPromotedNonBareConstructorParamIsOwnedByTheComposingPassOnce(): void + { + // The companion: a NON-promoted, non-bare constructor param (`?T $x`) is exempt from the + // position pass, so the composing pass keeps ownership of its direct leaf — exactly one + // `inner_variance` diagnostic, no double-report. (The bare-`T` immutable shape stays exempt by + // both; a promoted `public T $item` is owned by the position pass — see the test above.) + $source = "\n{\n public function __construct(?T \$x) {}\n}\n"; + $collector = new DiagnosticCollector(); + $registry = $this->registryFor($source, $collector); + + $registry->validateVariancePositions(); + $registry->validateInnerVariance(); + + self::assertCount(1, $collector->all()); + self::assertSame(InnerVarianceValidator::CODE_INNER_VARIANCE, $collector->all()[0]->code); + } + public function testViolationIsCollectedWithMemberLineInCheckMode(): void { // Line 5 holds `public function set(T $x)`. From 231104657e20d61ec0e3c94fb07f439e1ef1e505 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 26 Jun 2026 18:07:57 +0000 Subject: [PATCH 113/114] docs: document a covariant class consuming a contravariant generic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record that `pick(Comparator $c)` on a covariant `Box<+E>` (where Comparator is contravariant) is sound and now accepted — the variance guide's composition section gains the contra ∘ contra = covariant case (the element-consuming counterpart to the covariant immutable constructor), and the changelog notes nested type-parameters now route through the composing variance check instead of the bare outer position. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 11 +++++++++++ docs/syntax/variance.md | 22 ++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa811c1..b523351 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,17 @@ _In progress on this branch — content still accumulating; date set at tag time when positively provable, so the covariance now holds at runtime (`instanceof`, type hints) and not only at `check`. Previously such an upcast passed `check` but fatal'd at load. See [variance](docs/syntax/variance.md). +- **A contravariant generic may be consumed by a covariant class.** A method parameter typed + by a contravariant generic of the class's covariant parameter — `class Box<+E> { pick( + Comparator $c): ?E }` where `Comparator<-T>` — is now accepted: `E` sits in a + contravariant slot inside a contravariant parameter position, which composes to a covariant + position a `+E` may occupy (sound under upcast — a `Comparator` compares the `Book` + elements of a `Box` viewed as `Box`). Variance validation now routes every + type-constructor-nested type-parameter through the composing check (which already knew the + inner slot's variance) instead of judging it by the bare outer position, so this sound, + `mixed`-free `sortedWith`/`minWith`/`pick` shape compiles instead of being wrongly rejected. + A bare `E` in a parameter position is still rejected — only the composed position is + covariant. See [variance](docs/syntax/variance.md). ### Changed diff --git a/docs/syntax/variance.md b/docs/syntax/variance.md index f68f85c..4a16695 100644 --- a/docs/syntax/variance.md +++ b/docs/syntax/variance.md @@ -230,6 +230,28 @@ invariant, so the outer `+T` is rejected. The validator walks every generic class's method signatures, bounds, and defaults to apply this composition. +Composition can also *permit* a position that looks wrong at a glance. +A consuming method that takes a **contravariant** generic is sound on a +covariant class: + +```php +interface Comparator<-T> { public function compare(T $a, T $b): int; } + +class Box<+E> { + public function pick(Comparator $c): ?E { /* … */ } // ALLOWED +} +``` + +`E` is in a contravariant slot (`Comparator<-T>`) inside a contravariant +parameter position — contra ∘ contra = **covariant**, which a covariant +`+E` may occupy. (Under an upcast, a `Box` viewed as `Box` +takes a `Comparator`, which by contravariance compares the `Book` +elements — sound.) This is the element-consuming counterpart to the +covariant immutable constructor: a `mixed`-free, fluent +`sortedWith`/`minWith`/`pick` on a covariant collection. A **direct** +covariant `E` in a parameter (`pick(E $x)`) stays rejected — only the +*composed* position is covariant here, not the bare one. + ## Caveats - > ⚠️ **Not allowed on closures or arrows** — anonymous templates From 60decada19d368af5864f2bbe1a928f9c1953fae Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 26 Jun 2026 18:47:33 +0000 Subject: [PATCH 114/114] feat(monomorphize): localize the non-convergent-specialization diagnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the specialization fixed-point exceeds the depth cap, report the type family whose arguments nest the deepest — the tip of the growing tower — instead of dumping the whole registry. The message names the offending template and a representative spec, and explains the common cause (a member whose type re-wraps the receiver's own type family in a growing form). This turns an opaque whole-registry abort into an actionable, localized error; it does not change which programs compile. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/Compiler.php | 57 +++++++++++++++++-- .../Monomorphize/CompilerDepthCapTest.php | 7 ++- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index c69e200..ed51051 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -174,11 +174,7 @@ public function compile( $depth++; if ($depth > self::MAX_SPECIALIZATION_DEPTH) { - throw new RuntimeException(sprintf( - 'Nested generic specialization exceeded depth %d. Latest registry: %s', - self::MAX_SPECIALIZATION_DEPTH, - implode(', ', array_keys($registry->instantiations())), - )); + throw new RuntimeException(self::unconvergedSpecializationMessage($registry)); } } @@ -362,5 +358,56 @@ private static function relativePath(string $base, string $filepath): string } return basename($filepath); } + + /** + * Build a localized diagnostic for a specialization set that didn't converge: identify the type + * family whose arguments nest the deepest (the tip of the growing tower) and name it, instead of + * dumping the whole registry. The common cause is a method whose return type re-wraps the receiver's + * own type family in a growing form (`groupBy(): Map>` where Map's views re-expose List). + */ + private static function unconvergedSpecializationMessage(Registry $registry): string + { + $deepest = null; + $deepestDepth = -1; + foreach ($registry->instantiations() as $instantiation) { + $depth = 0; + foreach ($instantiation->concreteTypes as $arg) { + $depth = max($depth, self::typeRefDepth($arg)); + } + if ($depth > $deepestDepth) { + $deepestDepth = $depth; + $deepest = $instantiation; + } + } + + $family = $deepest === null ? '(unknown)' : $deepest->templateFqn; + $example = $deepest === null + ? '(none)' + : $deepest->templateFqn . '<' . implode(', ', array_map( + static fn (TypeRef $r): string => $r->canonical(), + $deepest->concreteTypes, + )) . '>'; + + return sprintf( + 'Generic specialization did not converge (exceeded depth %d): the type family rooted at "%s" ' + . 'grows without bound — e.g. "%s". This happens when a member\'s type re-wraps the receiver\'s ' + . 'own type family in a growing form (for example `groupBy(): Map>`, where Map\'s ' + . 'views re-expose List). Break the cycle: give the member a non-self-reintroducing type, or ' + . 'split the derivation so the growing type isn\'t reached through an unbounded chain.', + self::MAX_SPECIALIZATION_DEPTH, + $family, + $example, + ); + } + + /** The maximum nesting depth of a TypeRef's generic-argument tree (a non-generic ref is depth 0). */ + private static function typeRefDepth(TypeRef $ref): int + { + $max = 0; + foreach ($ref->args as $arg) { + $max = max($max, self::typeRefDepth($arg)); + } + return $ref->args === [] ? 0 : 1 + $max; + } } diff --git a/test/Transpiler/Monomorphize/CompilerDepthCapTest.php b/test/Transpiler/Monomorphize/CompilerDepthCapTest.php index 61368ce..fd5c737 100644 --- a/test/Transpiler/Monomorphize/CompilerDepthCapTest.php +++ b/test/Transpiler/Monomorphize/CompilerDepthCapTest.php @@ -52,7 +52,12 @@ public function testRecursiveTemplateExceedsDepthCapAndThrows(): void ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); $this->expectException(RuntimeException::class); - $this->expectExceptionMessageMatches('/Nested generic specialization exceeded depth \d+\./'); + // The localized diagnostic names the growing type family (here the recursive template) instead + // of dumping the whole registry. + $this->expectExceptionMessageMatches( + '/Generic specialization did not converge \(exceeded depth \d+\): the type family rooted at ' + . '"App\\\\[^"]*Recursive"/', + ); $compiler->compile( $sources, $sourceDir,