From 703cccd36e74a58a1beef647d98f8e7108450346 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 24 Jul 2026 10:13:53 +0000 Subject: [PATCH 01/46] test: make compile-fixture verify files explicit typed closures Each executable verify file under test/fixture/compile/*/verify/ relied on an implicit, untyped $fixture variable being in the enclosing test method's scope at the point of `require`. The dependency was invisible from the file, untyped, and enforced only by a docblock convention across 26 driver methods. Each verify file now returns a `function (CompiledFixture $fixture): void` that runs its assertions when invoked, and each driver calls it explicitly: $runtime = require __DIR__ . '/.../verify/runtime.php'; $runtime($fixture); The fixture dependency is now an explicit typed parameter. Behavior is unchanged: the inner `require $fixture->targetDir . '/Use.php'` still runs in the closure scope (so locals like $result are produced as before), assertion failures still propagate through the invocation, and the try/finally still cleans up the temp dir. Also drop the decorative `echo "OK\n"` lines from the 11 files that had them: stdout is never inspected, so the `Assert::` calls already signal pass/fail. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/TestSupport/CompiledFixture.php | 15 +++- .../ArraySugarIntegrationTest.php | 6 +- .../Monomorphize/ArrowSpecializationTest.php | 21 +++-- .../BoundedGenericIntegrationTest.php | 3 +- .../BuiltinInterfaceViaUseIntegrationTest.php | 3 +- .../Monomorphize/ClosureArrowDefaultsTest.php | 12 ++- .../ClosureConformanceIntegrationTest.php | 36 +++++--- .../ClosureDispatcherIntegrationTest.php | 9 +- .../DefaultedGenericIntegrationTest.php | 3 +- .../DispatcherFixtureIntegrationTest.php | 9 +- .../EnclosingParamBoundIntegrationTest.php | 42 ++++++--- .../Monomorphize/FreeSymbolRequalifyTest.php | 12 ++- .../GenericExceptionCatchIntegrationTest.php | 3 +- .../GenericFunctionIntegrationTest.php | 6 +- .../GenericInterfaceIntegrationTest.php | 3 +- .../GenericMethodIntegrationTest.php | 24 +++-- .../GenericTraitAdaptationTest.php | 6 +- .../GroupImportClassRequalifyTest.php | 3 +- .../KeywordNamedGenericMethodTest.php | 6 +- .../MarkerAlignmentIntegrationTest.php | 12 ++- .../MultiTypeGenericsIntegrationTest.php | 3 +- .../NestedGenericsIntegrationTest.php | 3 +- .../QualifiedCallSiteIntegrationTest.php | 9 +- .../UseClosureSpecializationTest.php | 15 ++-- .../UseImportGenericClauseTest.php | 3 +- .../VarianceEdgeIntegrationTest.php | 12 ++- .../array_sugar/verify/nullable_return.php | 47 +++++----- .../verify/runtime.php | 13 +-- .../verify/runtime.php | 9 +- .../verify/runtime.php | 7 +- .../arrow_defaults_single/verify/runtime.php | 9 +- .../verify/runtime.php | 9 +- .../verify/runtime.php | 9 +- .../verify/runtime.php | 7 +- .../arrow_no_captures/verify/runtime.php | 7 +- .../verify/runtime.php | 7 +- .../verify/runtime.php | 7 +- .../verify/runtime.php | 17 ++-- .../verify/runtime.php | 13 +-- .../verify/runtime.php | 87 ++++++++++--------- .../verify/runtime.php | 9 +- .../verify/runtime.php | 9 +- .../verify/runtime.php | 9 +- .../verify/runtime.php | 9 +- .../verify/runtime.php | 9 +- .../verify/runtime.php | 9 +- .../verify/runtime.php | 25 +++--- .../verify/runtime.php | 11 ++- .../verify/runtime.php | 23 ++--- .../verify/runtime.php | 9 +- .../verify/runtime.php | 25 +++--- .../verify/runtime.php | 9 +- .../verify/runtime.php | 9 +- .../verify/runtime.php | 11 ++- .../verify/runtime.php | 13 +-- .../verify/runtime.php | 11 ++- .../verify/runtime.php | 23 ++--- .../verify/runtime.php | 15 ++-- .../verify/runtime.php | 23 ++--- .../closure_use_by_ref/verify/runtime.php | 11 ++- .../closure_use_by_value/verify/runtime.php | 11 ++- .../verify/runtime.php | 9 +- .../verify/runtime.php | 11 ++- .../verify/runtime.php | 13 +-- .../verify/runtime.php | 12 +-- .../verify/runtime.php | 16 ++-- .../verify/runtime.php | 13 +-- .../verify/runtime.php | 11 ++- .../verify/runtime.php | 11 ++- .../verify/runtime.php | 10 ++- .../verify/runtime.php | 13 +-- .../verify/runtime.php | 11 ++- .../verify/runtime.php | 9 +- .../verify/runtime.php | 11 ++- .../verify/runtime.php | 11 ++- .../verify/runtime.php | 9 +- .../verify/runtime.php | 10 ++- .../verify/runtime.php | 10 ++- .../verify/runtime.php | 12 +-- .../verify/runtime.php | 10 ++- .../verify/runtime.php | 11 ++- .../free_symbol_requalify/verify/runtime.php | 17 ++-- .../free_symbol_use_import/verify/runtime.php | 11 ++- .../verify/runtime.php | 43 ++++----- .../verify/runtime.php | 69 ++++++++------- .../verify/catch_runtime.php | 71 +++++++-------- .../verify/runtime_execution.php | 23 ++--- .../verify/runtime.php | 13 +-- .../verify/specialized_interface_runtime.php | 37 ++++---- .../compile/generic_method/verify/runtime.php | 27 +++--- .../verify/runtime.php | 13 +-- .../verify/runtime.php | 23 ++--- .../verify/runtime.php | 23 ++--- .../verify/runtime.php | 25 +++--- .../verify/runtime.php | 13 +-- .../verify/runtime.php | 11 ++- .../verify/runtime.php | 15 ++-- .../verify/runtime.php | 15 ++-- .../verify/runtime.php | 15 ++-- .../verify/runtime.php | 17 ++-- .../verify/runtime.php | 11 ++- .../verify/runtime.php | 13 +-- .../verify/type_error_on_wrong_slot.php | 47 +++++----- .../verify/runtime.php | 17 ++-- .../verify/nested_specialization_runtime.php | 45 +++++----- .../verify/runtime.php | 33 +++---- .../verify/runtime.php | 29 ++++--- .../verify/runtime.php | 25 +++--- .../same_line_marker_pairs/verify/runtime.php | 33 +++---- .../verify/runtime.php | 21 +++-- .../verify/runtime.php | 19 ++-- .../turbofish_fcc_closure/verify/runtime.php | 21 +++-- .../use_trait_generic/verify/runtime.php | 9 +- .../verify/runtime.php | 11 ++- 114 files changed, 1100 insertions(+), 748 deletions(-) diff --git a/test/TestSupport/CompiledFixture.php b/test/TestSupport/CompiledFixture.php index da94f7e2..3c21b423 100644 --- a/test/TestSupport/CompiledFixture.php +++ b/test/TestSupport/CompiledFixture.php @@ -31,16 +31,23 @@ * `#[RunInSeparateProcess]` (or use `setUpBeforeClass` for an entire * `TestCase` that shares one fixture across methods). * - * Failure attribution: when a verify file calls `Assert::*` and it - * fails, the exception's throw site is the verify file (`path:line`), - * and the `require` frame is the calling `testFoo` method — PHPUnit + * Verify contract: a verify file `return`s a + * `function (CompiledFixture $fixture): void` that runs its `Assert::*` + * calls when invoked, so the fixture dependency is an explicit typed + * parameter rather than an implicit in-scope variable. The driver + * loads the closure with `require` and calls it with the fixture. + * + * Failure attribution: when a verify file's `Assert::*` fails, the + * exception's throw site is the verify file (`path:line`), and the + * closure-invocation frame is the calling `testFoo` method — PHPUnit * reports both. * * Usage: * $fixture = CompiledFixture::compile($sourceDir, 'array-sugar'); * $fixture->registerAutoload('App\\ArraySugar\\'); * try { - * require __DIR__ . '/../../fixture/compile/array_sugar/verify/foo.php'; + * $runtime = require __DIR__ . '/../../fixture/compile/array_sugar/verify/foo.php'; + * $runtime($fixture); * } finally { * $fixture->cleanup(); * } diff --git a/test/Transpiler/Monomorphize/ArraySugarIntegrationTest.php b/test/Transpiler/Monomorphize/ArraySugarIntegrationTest.php index 2891da61..62a04f74 100644 --- a/test/Transpiler/Monomorphize/ArraySugarIntegrationTest.php +++ b/test/Transpiler/Monomorphize/ArraySugarIntegrationTest.php @@ -84,7 +84,8 @@ public function testNullableReturnEnforcesConcreteType(): void $fixture = CompiledFixture::compile($this->sourceDir, 'array-sugar-verify'); $fixture->registerAutoload('App\\ArraySugar\\'); try { - require __DIR__ . '/../../fixture/compile/array_sugar/verify/nullable_return.php'; + $runtime = require __DIR__ . '/../../fixture/compile/array_sugar/verify/nullable_return.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -104,7 +105,8 @@ public function testSugarBeforeGenericClosureStillSpecializes(): void ?: throw new RuntimeException('Fixture missing'); $fixture = CompiledFixture::compile($source, 'array-sugar-marker-offset'); try { - require __DIR__ . '/../../fixture/compile/array_sugar_before_generic_closure/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/array_sugar_before_generic_closure/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/ArrowSpecializationTest.php b/test/Transpiler/Monomorphize/ArrowSpecializationTest.php index a556447b..5ac16b78 100644 --- a/test/Transpiler/Monomorphize/ArrowSpecializationTest.php +++ b/test/Transpiler/Monomorphize/ArrowSpecializationTest.php @@ -94,7 +94,8 @@ public function testArrowSpecializationEndToEndSingleCapture(): void 'arrow-capture', ); try { - require __DIR__ . '/../../fixture/compile/arrow_capture_at_declaration/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/arrow_capture_at_declaration/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -108,7 +109,8 @@ public function testArrowSpecializationEndToEndMultipleCaptures(): void 'arrow-multi', ); try { - require __DIR__ . '/../../fixture/compile/arrow_multiple_captures/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/arrow_multiple_captures/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -122,7 +124,8 @@ public function testArrowSpecializationEndToEndNoCaptures(): void 'arrow-empty', ); try { - require __DIR__ . '/../../fixture/compile/arrow_no_captures/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/arrow_no_captures/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -138,7 +141,8 @@ public function testArrowSpecializationCaptureShadowingParamName(): void 'arrow-shadow', ); try { - require __DIR__ . '/../../fixture/compile/arrow_capture_shadowing/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/arrow_capture_shadowing/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -164,7 +168,8 @@ public function testArrowSpecializationMultipleArgTuples(): void $out, ); - require __DIR__ . '/../../fixture/compile/arrow_multiple_arg_tuples/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/arrow_multiple_arg_tuples/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -218,7 +223,8 @@ public function testArrowSpecializationReservedArgsCaptureAlsoTriggersRename(): $out, ); - require __DIR__ . '/../../fixture/compile/arrow_reserved_args_capture/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/arrow_reserved_args_capture/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -250,7 +256,8 @@ public function testArrowSpecializationReservedCaptureAutoRenamesDispatcherParam $out, ); - require __DIR__ . '/../../fixture/compile/arrow_reserved_tag_capture/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/arrow_reserved_tag_capture/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php b/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php index cd88684c..a5f6dfbe 100644 --- a/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php +++ b/test/Transpiler/Monomorphize/BoundedGenericIntegrationTest.php @@ -428,7 +428,8 @@ public function testScalarAliasClassTypeArgumentsResolveAndRunAtRuntime(): void ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/scalar_alias_class_resolves/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/scalar_alias_class_resolves/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/BuiltinInterfaceViaUseIntegrationTest.php b/test/Transpiler/Monomorphize/BuiltinInterfaceViaUseIntegrationTest.php index 0981453b..df4db297 100644 --- a/test/Transpiler/Monomorphize/BuiltinInterfaceViaUseIntegrationTest.php +++ b/test/Transpiler/Monomorphize/BuiltinInterfaceViaUseIntegrationTest.php @@ -139,7 +139,8 @@ public function testSpecializedClassLoadsAndResolvesBuiltinsAtRuntime(): void $fixture = CompiledFixture::compile($this->sourceDir, 'builtin-via-use-runtime'); $fixture->registerAutoload('App\\BuiltinViaUse\\'); try { - require __DIR__ . '/../../fixture/compile/builtin_interface_via_use/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/builtin_interface_via_use/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/ClosureArrowDefaultsTest.php b/test/Transpiler/Monomorphize/ClosureArrowDefaultsTest.php index 78c3bccb..a41b97c3 100644 --- a/test/Transpiler/Monomorphize/ClosureArrowDefaultsTest.php +++ b/test/Transpiler/Monomorphize/ClosureArrowDefaultsTest.php @@ -29,7 +29,8 @@ public function testClosureWithSingleDefaultUsesPadding(): void 'cdef-single', ); try { - require __DIR__ . '/../../fixture/compile/closure_defaults_single/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_defaults_single/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -43,7 +44,8 @@ public function testArrowWithSingleDefaultUsesPadding(): void 'adef-single', ); try { - require __DIR__ . '/../../fixture/compile/arrow_defaults_single/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/arrow_defaults_single/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -135,7 +137,8 @@ public function testDefaultOnClosureWithUseClause(): void 'cdef-use', ); try { - require __DIR__ . '/../../fixture/compile/closure_defaults_with_use/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_defaults_with_use/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -150,7 +153,8 @@ public function testDefaultOnArrowWithImplicitCapture(): void 'adef-cap', ); try { - require __DIR__ . '/../../fixture/compile/arrow_defaults_with_implicit_capture/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/arrow_defaults_with_implicit_capture/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php b/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php index 6c563967..a0e44f69 100644 --- a/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php +++ b/test/Transpiler/Monomorphize/ClosureConformanceIntegrationTest.php @@ -25,7 +25,8 @@ public function testConformingClosuresCompileEraseAndRun(): void 'closure-conformance-run', ); try { - require __DIR__ . '/../../fixture/compile/closure_conformance_runtime/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_conformance_runtime/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -43,7 +44,8 @@ public function testDnfGroupedSignaturesCompileEraseAndRun(): void 'closure-conformance-dnf', ); try { - require __DIR__ . '/../../fixture/compile/closure_conformance_dnf_runtime/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_conformance_dnf_runtime/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -61,7 +63,8 @@ public function testUserFunctionNamedClosureInExpressionColonsExecutes(): void 'closure-named-user-fn', ); try { - require __DIR__ . '/../../fixture/compile/closure_named_user_function_runtime/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_named_user_function_runtime/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -78,7 +81,8 @@ public function testArraySugarSignaturesCompileEraseAndRun(): void 'closure-conformance-sugar', ); try { - require __DIR__ . '/../../fixture/compile/closure_conformance_array_sugar_runtime/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_conformance_array_sugar_runtime/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -96,7 +100,8 @@ public function testExceptionFactoryAgainstBuiltinThrowableTargetCompilesAndRuns ); $fixture->registerAutoload('App\\ClosureBuiltinOk\\'); try { - require __DIR__ . '/../../fixture/compile/closure_conformance_builtin_ok/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_conformance_builtin_ok/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -124,7 +129,8 @@ public function testConformingClosureArgumentCompilesEraseAndRuns(): void ); $fixture->registerAutoload('App\\ClosureArgRun\\'); try { - require __DIR__ . '/../../fixture/compile/closure_arg_instance_runtime/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_arg_instance_runtime/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -152,7 +158,8 @@ public function testConformingStaticClosureArgumentCompilesEraseAndRuns(): void ); $fixture->registerAutoload('App\\ClosureArgStaticRun\\'); try { - require __DIR__ . '/../../fixture/compile/closure_arg_static_runtime/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_arg_static_runtime/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -180,7 +187,8 @@ public function testConformingFreeFunctionClosureArgumentCompilesEraseAndRuns(): ); $fixture->registerAutoload('App\\ClosureArgFnRun\\'); try { - require __DIR__ . '/../../fixture/compile/closure_arg_free_fn_runtime/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_arg_free_fn_runtime/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -208,7 +216,8 @@ public function testConformingPlainMethodClosureArgumentCompilesEraseAndRuns(): ); $fixture->registerAutoload('App\\PlainArgRun\\'); try { - require __DIR__ . '/../../fixture/compile/closure_arg_plain_runtime/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_arg_plain_runtime/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -236,7 +245,8 @@ public function testConformingPlainFreeFunctionClosureArgumentCompilesEraseAndRu ); $fixture->registerAutoload('App\\PlainFnArgRun\\'); try { - require __DIR__ . '/../../fixture/compile/closure_arg_plain_fn_runtime/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_arg_plain_fn_runtime/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -265,7 +275,8 @@ public function testBucket3SelfCallClosureArgumentCompilesEraseAndRuns(): void ); $fixture->registerAutoload('App\\Bucket3Run\\'); try { - require __DIR__ . '/../../fixture/compile/closure_arg_bucket3_runtime/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_arg_bucket3_runtime/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -295,7 +306,8 @@ public function testGroundedGenericClosureConformsWhenTypeParameterResolves(): v ); $fixture->registerAutoload('App\\ClosureGroundRuntime\\'); try { - require __DIR__ . '/../../fixture/compile/closure_conformance_grounded_runtime/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_conformance_grounded_runtime/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest.php b/test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest.php index 715316fb..6da5eef2 100644 --- a/test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest.php +++ b/test/Transpiler/Monomorphize/ClosureDispatcherIntegrationTest.php @@ -126,7 +126,8 @@ public function testRuntimeRoutingThroughDispatcher(): void 'disp-routing', ); try { - require __DIR__ . '/../../fixture/compile/closure_dispatcher_runtime_routing/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_dispatcher_runtime_routing/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -142,7 +143,8 @@ public function testUnknownTagAtRuntimeThrows(): void 'disp-unknown', ); try { - require __DIR__ . '/../../fixture/compile/closure_dispatcher_unknown_tag/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_dispatcher_unknown_tag/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -291,7 +293,8 @@ public function testFirstClassCallableTurbofishClosureEmitsValidForwardingClosur 'fcc-closure', ); try { - require __DIR__ . '/../../fixture/compile/turbofish_fcc_closure/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/turbofish_fcc_closure/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/DefaultedGenericIntegrationTest.php b/test/Transpiler/Monomorphize/DefaultedGenericIntegrationTest.php index 49eec68f..fb1a15a3 100644 --- a/test/Transpiler/Monomorphize/DefaultedGenericIntegrationTest.php +++ b/test/Transpiler/Monomorphize/DefaultedGenericIntegrationTest.php @@ -102,7 +102,8 @@ public function testBareNewSelfInNonDefaultsGenericBodyIsNotRejectedAndRuns(): v ); try { $fixture->registerAutoload('App\\BareNewSelfInGenericBody'); - require __DIR__ . '/../../fixture/compile/bare_new_self_in_generic_body/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/bare_new_self_in_generic_body/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/DispatcherFixtureIntegrationTest.php b/test/Transpiler/Monomorphize/DispatcherFixtureIntegrationTest.php index 5d7a7a36..723a12c5 100644 --- a/test/Transpiler/Monomorphize/DispatcherFixtureIntegrationTest.php +++ b/test/Transpiler/Monomorphize/DispatcherFixtureIntegrationTest.php @@ -68,7 +68,8 @@ public function testArrowFixtureSynthesizesUseClauseFromImplicitCapture(): void $out, ); - require __DIR__ . '/../../fixture/compile/closure_dispatcher_arrow/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_dispatcher_arrow/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -100,7 +101,8 @@ public function testUseClauseFixturePropagatesByRefThroughDispatcher(): void preg_match_all('/function closure_f_T_[0-9a-f]+\(/', $out, $matches); self::assertCount(2, $matches[0]); - require __DIR__ . '/../../fixture/compile/closure_dispatcher_use_clause/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_dispatcher_use_clause/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -131,7 +133,8 @@ public function testDefaultsFixturePadsEmptyTurbofish(): void preg_match_all('/function closure_f_T_[0-9a-f]+\(/', $out, $fMatches); self::assertCount(2, $fMatches[0]); - require __DIR__ . '/../../fixture/compile/closure_dispatcher_defaults/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_dispatcher_defaults/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php index d2e6104b..8a4f2683 100644 --- a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php +++ b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php @@ -732,7 +732,8 @@ public function testErasableForwardingRunsAtRuntime(): void ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_forwarding/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_forwarding/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -749,7 +750,8 @@ public function testTwoEnclosingBoundedParamsEraseAndRunAtRuntime(): void ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_two_params/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_two_params/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -766,7 +768,8 @@ public function testMultiClassParamErasureMangleKeysOnTheBoundsReferentAtRuntime ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_map_multiparam/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_map_multiparam/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -783,7 +786,8 @@ public function testTwoTurbofishTypesCollapseToOneWidenedMemberAtRuntime(): void ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_param_widening/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_param_widening/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -801,7 +805,8 @@ public function testInheritedErasableMemberResolvesAtRuntime(): void ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_inherited/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_inherited/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -819,7 +824,8 @@ public function testErasureIsVarianceSafeOnTheCovariantChainAtRuntime(): void ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_covariant_chain/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/enclosing_bound_erasure_covariant_chain/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -839,7 +845,8 @@ public function testCovariantInterfaceUpcastResolvesTheErasedMemberAtRuntime(): ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/enclosing_bound_interface_upcast/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/enclosing_bound_interface_upcast/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -859,7 +866,8 @@ public function testSubInterfaceMethodDirectEmittedUnderUpcastRunsAtRuntime(): v ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/enclosing_bound_subinterface_direct_emit/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/enclosing_bound_subinterface_direct_emit/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -878,7 +886,8 @@ public function testDirectEmittedBodyResolvesTheClassParamToTheUpcastSourceAtRun ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/enclosing_bound_subinterface_structural_class_param/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/enclosing_bound_subinterface_structural_class_param/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -896,7 +905,8 @@ public function testMultiParamCovariantInterfaceUpcastResolvesAtRuntime(): void ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/enclosing_bound_interface_upcast_map/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/enclosing_bound_interface_upcast_map/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -916,7 +926,8 @@ public function testVarianceEdgeDoesNotOverwriteASourceParentAtRuntime(): void ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/variance_edge_preserves_source_parent/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/variance_edge_preserves_source_parent/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -1264,7 +1275,8 @@ public function testNestedGenericDiamondCovariantUpcastCompilesAndRuns(): void ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/covariant_upcast_nested_generic_diamond/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/covariant_upcast_nested_generic_diamond/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -1287,7 +1299,8 @@ public function testMultiPathDiamondClosureSuppliesEverySiblingAcrossInterfacesA ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/covariant_upcast_multipath_diamond/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/covariant_upcast_multipath_diamond/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -1307,7 +1320,8 @@ public function testReturnEnclosingParamOnParentlessBaseSurvivesPlainUpcast(): v ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/covariant_upcast_return_enclosing_inherited/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/covariant_upcast_return_enclosing_inherited/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/FreeSymbolRequalifyTest.php b/test/Transpiler/Monomorphize/FreeSymbolRequalifyTest.php index 1953ce66..a0e69dd2 100644 --- a/test/Transpiler/Monomorphize/FreeSymbolRequalifyTest.php +++ b/test/Transpiler/Monomorphize/FreeSymbolRequalifyTest.php @@ -31,7 +31,8 @@ public function testBareQualifiedConstAndBuiltinReferencesBindCorrectlyAtRuntime ); try { $fixture->registerAutoload('App\\FreeSym'); - require __DIR__ . '/../../fixture/compile/free_symbol_requalify/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/free_symbol_requalify/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -49,7 +50,8 @@ public function testUseFunctionAndUseConstImportsBindTheImportedNamespaceAtRunti ); try { $fixture->registerAutoload('App', 'Vendor'); - require __DIR__ . '/../../fixture/compile/free_symbol_use_import/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/free_symbol_use_import/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -67,7 +69,8 @@ public function testGroupUseFunctionAndConstImportsBindTheImportedNamespaceAtRun ); try { $fixture->registerAutoload('App', 'Vendor'); - require __DIR__ . '/../../fixture/compile/free_symbol_group_use_import/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/free_symbol_group_use_import/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -86,7 +89,8 @@ public function testGapFilledCovariantMembersReQualifyTheirFreeSymbolsAtRuntime( ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/covariant_gapfill_free_symbol/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/covariant_gapfill_free_symbol/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/GenericExceptionCatchIntegrationTest.php b/test/Transpiler/Monomorphize/GenericExceptionCatchIntegrationTest.php index a93e805b..f3048b64 100644 --- a/test/Transpiler/Monomorphize/GenericExceptionCatchIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericExceptionCatchIntegrationTest.php @@ -57,7 +57,8 @@ public function testGenericCatchDiscriminatesBySpecializationAtRuntime(): void $fixture = CompiledFixture::compile($this->sourceDir, 'generic-catch-runtime'); $fixture->registerAutoload('App\\GenericExceptionCatch\\'); try { - require __DIR__ . '/../../fixture/compile/generic_exception_catch/verify/catch_runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/generic_exception_catch/verify/catch_runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php b/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php index 56e42176..d95b5250 100644 --- a/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php @@ -84,7 +84,8 @@ public function testRuntimeExecutionOfSpecializedFunctions(): void { $fixture = CompiledFixture::compile($this->sourceDir, 'genfn-runtime'); try { - require __DIR__ . '/../../fixture/compile/generic_function/verify/runtime_execution.php'; + $runtime = require __DIR__ . '/../../fixture/compile/generic_function/verify/runtime_execution.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -269,7 +270,8 @@ public function testBareTopLevelStripPreservesAllNonTemplateStatements(): void $funcsOut, ); - require __DIR__ . '/../../fixture/compile/generic_function_bare_top_level/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/generic_function_bare_top_level/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/GenericInterfaceIntegrationTest.php b/test/Transpiler/Monomorphize/GenericInterfaceIntegrationTest.php index 9766ab42..1b9493c3 100644 --- a/test/Transpiler/Monomorphize/GenericInterfaceIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericInterfaceIntegrationTest.php @@ -119,7 +119,8 @@ public function testSpecializedClassIsInstanceOfSpecializedInterfaceAtRuntime(): $fixture = CompiledFixture::compile($this->sourceDir, 'generic-interface-runtime'); $fixture->registerAutoload('App\\GenericInterface\\'); try { - require __DIR__ . '/../../fixture/compile/generic_interface/verify/specialized_interface_runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/generic_interface/verify/specialized_interface_runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php index 981bedee..42aff925 100644 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php @@ -92,7 +92,8 @@ public function testRuntimeExecutionPreservesGenericMethodSemantics(): void 'genmethod-runtime', ); try { - require __DIR__ . '/../../fixture/compile/generic_method/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/generic_method/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -240,7 +241,8 @@ public function testSelfWithTypeArgsCompilesEndToEnd(): void ); $fixture->registerAutoload('App\\GenericMethodSelfReturnTypeArgs'); - require __DIR__ . '/../../fixture/compile/generic_method_self_with_type_args/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/generic_method_self_with_type_args/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -271,7 +273,8 @@ public function testInstanceMethodGenericThisReceiverSpecializes(): void $util, ); - require __DIR__ . '/../../fixture/compile/generic_method_this_receiver/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/generic_method_this_receiver/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -342,7 +345,8 @@ public function testInstanceMethodGenericLocalVariableReceiverSpecializes(): voi $use, ); - require __DIR__ . '/../../fixture/compile/generic_method_local_variable_receiver/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/generic_method_local_variable_receiver/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -1185,7 +1189,8 @@ public function testNewSelfTurbofishCompilesEndToEnd(): void ); $fixture->registerAutoload('App\\GenericMethodNewSelfTurbofish'); - require __DIR__ . '/../../fixture/compile/generic_method_new_self_turbofish/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/generic_method_new_self_turbofish/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -1217,7 +1222,8 @@ public function testNewStaticTurbofishCompilesEndToEnd(): void ); $fixture->registerAutoload('App\\GenericMethodNewStaticTurbofish'); - require __DIR__ . '/../../fixture/compile/generic_method_new_static_turbofish/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/generic_method_new_static_turbofish/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -1327,7 +1333,8 @@ public function testGenericMethodResolvesThroughInheritance(): void ); $fixture->registerAutoload('App\\GenericMethodThroughInheritance'); - require __DIR__ . '/../../fixture/compile/generic_method_through_inheritance/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/generic_method_through_inheritance/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -1353,7 +1360,8 @@ public function testStaticGenericMethodResolvesThroughInheritance(): void 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'; + $runtime = require __DIR__ . '/../../fixture/compile/generic_static_method_through_inheritance/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/GenericTraitAdaptationTest.php b/test/Transpiler/Monomorphize/GenericTraitAdaptationTest.php index 7c158d02..035de8e9 100644 --- a/test/Transpiler/Monomorphize/GenericTraitAdaptationTest.php +++ b/test/Transpiler/Monomorphize/GenericTraitAdaptationTest.php @@ -28,7 +28,8 @@ public function testCrossStatementInsteadofAndAliasResolveAtRuntime(): void ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/generic_trait_adaptation/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/generic_trait_adaptation/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -46,7 +47,8 @@ public function testMixedGenericAndPlainTraitAdaptationResolvesAtRuntime(): void ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/generic_trait_adaptation_mixed/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/generic_trait_adaptation_mixed/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/GroupImportClassRequalifyTest.php b/test/Transpiler/Monomorphize/GroupImportClassRequalifyTest.php index 9925dd0e..142165cf 100644 --- a/test/Transpiler/Monomorphize/GroupImportClassRequalifyTest.php +++ b/test/Transpiler/Monomorphize/GroupImportClassRequalifyTest.php @@ -29,7 +29,8 @@ public function testGroupImportedClassReQualifiesInARelocatedBodyAtRuntime(): vo ); try { $fixture->registerAutoload('App', 'Vendor'); - require __DIR__ . '/../../fixture/compile/group_import_class_requalify/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/group_import_class_requalify/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/KeywordNamedGenericMethodTest.php b/test/Transpiler/Monomorphize/KeywordNamedGenericMethodTest.php index 793b2e47..0c1b8bd4 100644 --- a/test/Transpiler/Monomorphize/KeywordNamedGenericMethodTest.php +++ b/test/Transpiler/Monomorphize/KeywordNamedGenericMethodTest.php @@ -30,7 +30,8 @@ public function testKeywordNamedGenericMethodSpecializesAndRunsViaBothCallSites( ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/keyword_named_generic_method/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/keyword_named_generic_method/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -49,7 +50,8 @@ public function testKeywordNamedNonGenericMethodsAndListDestructuringPassThrough ); try { $fixture->registerAutoload('App'); - require __DIR__ . '/../../fixture/compile/keyword_nongeneric_passthrough/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/keyword_nongeneric_passthrough/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/MarkerAlignmentIntegrationTest.php b/test/Transpiler/Monomorphize/MarkerAlignmentIntegrationTest.php index 6e1dd392..13439aa1 100644 --- a/test/Transpiler/Monomorphize/MarkerAlignmentIntegrationTest.php +++ b/test/Transpiler/Monomorphize/MarkerAlignmentIntegrationTest.php @@ -26,7 +26,8 @@ public function testDeclarationsAfterMultiLineSpansStillSpecializeAndRun(): void ); $fixture->registerAutoload('App\\MultilineMarkers'); try { - require __DIR__ . '/../../fixture/compile/multiline_generic_markers/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/multiline_generic_markers/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -41,7 +42,8 @@ public function testAttributedAndStaticGenericClosuresSpecializeAndRun(): void ); $fixture->registerAutoload('App\\AttributedClosures'); try { - require __DIR__ . '/../../fixture/compile/attributed_generic_closures/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/attributed_generic_closures/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -56,7 +58,8 @@ public function testSameLineSameSpellingPairsBindTheirOwnMarkersAndRun(): void ); $fixture->registerAutoload('App\\SameLinePairs'); try { - require __DIR__ . '/../../fixture/compile/same_line_marker_pairs/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/same_line_marker_pairs/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -71,7 +74,8 @@ public function testSplitDeclarationHeadersSpecializeAndRun(): void ); $fixture->registerAutoload('App\\SplitHeaders'); try { - require __DIR__ . '/../../fixture/compile/split_declaration_headers/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/split_declaration_headers/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/MultiTypeGenericsIntegrationTest.php b/test/Transpiler/Monomorphize/MultiTypeGenericsIntegrationTest.php index 2b0d6c00..1e28e615 100644 --- a/test/Transpiler/Monomorphize/MultiTypeGenericsIntegrationTest.php +++ b/test/Transpiler/Monomorphize/MultiTypeGenericsIntegrationTest.php @@ -197,7 +197,8 @@ public function testRuntimeTypeErrorOnWrongSlotType(): void $fixture = CompiledFixture::compile($this->sourceDir, 'multi-type-runtime'); $fixture->registerAutoload('App\\MultiType\\'); try { - require __DIR__ . '/../../fixture/compile/multi_type/verify/type_error_on_wrong_slot.php'; + $runtime = require __DIR__ . '/../../fixture/compile/multi_type/verify/type_error_on_wrong_slot.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/NestedGenericsIntegrationTest.php b/test/Transpiler/Monomorphize/NestedGenericsIntegrationTest.php index df6363da..5028271c 100644 --- a/test/Transpiler/Monomorphize/NestedGenericsIntegrationTest.php +++ b/test/Transpiler/Monomorphize/NestedGenericsIntegrationTest.php @@ -148,7 +148,8 @@ public function testRuntimeReflectionAndTypeErrorOnNestedSpecialization(): void $fixture = CompiledFixture::compile($sourceDir, 'nested-runtime'); $fixture->registerAutoload('App\\NestedTypehint\\'); try { - require __DIR__ . '/../../fixture/compile/nested_typehint/verify/nested_specialization_runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/nested_typehint/verify/nested_specialization_runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/QualifiedCallSiteIntegrationTest.php b/test/Transpiler/Monomorphize/QualifiedCallSiteIntegrationTest.php index 85a2fe04..c2e3aa5b 100644 --- a/test/Transpiler/Monomorphize/QualifiedCallSiteIntegrationTest.php +++ b/test/Transpiler/Monomorphize/QualifiedCallSiteIntegrationTest.php @@ -26,7 +26,8 @@ public function testQualifiedSpellingsSpecializeAndRun(): void ); $fixture->registerAutoload('App\\QualifiedCalls'); try { - require __DIR__ . '/../../fixture/compile/qualified_generic_call_sites/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/qualified_generic_call_sites/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -41,7 +42,8 @@ public function testRelativeNamesInTemplatesBindToTheCurrentNamespaceAndRun(): v ); $fixture->registerAutoload('App\\RelativeTemplates', 'Other'); try { - require __DIR__ . '/../../fixture/compile/relative_names_in_templates/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/relative_names_in_templates/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -56,7 +58,8 @@ public function testQualifiedBareNewsSynthesizeDefaultsAndRun(): void ); $fixture->registerAutoload('App\\QualifiedDefaults', 'Other'); try { - require __DIR__ . '/../../fixture/compile/qualified_bare_new_defaults/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/qualified_bare_new_defaults/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/UseClosureSpecializationTest.php b/test/Transpiler/Monomorphize/UseClosureSpecializationTest.php index 77f31283..89045994 100644 --- a/test/Transpiler/Monomorphize/UseClosureSpecializationTest.php +++ b/test/Transpiler/Monomorphize/UseClosureSpecializationTest.php @@ -31,7 +31,8 @@ public function testUseClauseByValueRuntimeContract(): void 'use-byval', ); try { - require __DIR__ . '/../../fixture/compile/closure_use_by_value/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_use_by_value/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -45,7 +46,8 @@ public function testUseClauseByRefCaptureMutatesOuter(): void 'use-byref', ); try { - require __DIR__ . '/../../fixture/compile/closure_use_by_ref/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_use_by_ref/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -83,7 +85,8 @@ public function testUseClauseMultipleMixedRefAndValueCaptures(): void 'use-mixed', ); try { - require __DIR__ . '/../../fixture/compile/closure_use_multiple_mixed_captures/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_use_multiple_mixed_captures/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -109,7 +112,8 @@ public function testUseClauseMultipleArgTuples(): void $out, ); - require __DIR__ . '/../../fixture/compile/closure_use_multiple_arg_tuples/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_use_multiple_arg_tuples/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -173,7 +177,8 @@ public function testUseClauseCaptureNamedXphpArgsAutoRenames(): void 'use-reserved', ); try { - require __DIR__ . '/../../fixture/compile/closure_use_capture_named_xphp_args/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/closure_use_capture_named_xphp_args/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/UseImportGenericClauseTest.php b/test/Transpiler/Monomorphize/UseImportGenericClauseTest.php index af3e50f4..d17dc5d9 100644 --- a/test/Transpiler/Monomorphize/UseImportGenericClauseTest.php +++ b/test/Transpiler/Monomorphize/UseImportGenericClauseTest.php @@ -138,7 +138,8 @@ public function testGenericTraitUseStillSpecializesAndRuns(): void ); try { $fixture->registerAutoload('App\\UseTraitGeneric'); - require __DIR__ . '/../../fixture/compile/use_trait_generic/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/use_trait_generic/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php b/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php index 2bc8a125..1e0a2e9f 100644 --- a/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php +++ b/test/Transpiler/Monomorphize/VarianceEdgeIntegrationTest.php @@ -84,7 +84,8 @@ public function testCovariantImmutableCollectionTakesTypedConstructorInput(): vo self::assertStringNotContainsString('final class', $combined); $fixture->registerAutoload('App\\CovariantConstructor'); - require __DIR__ . '/../../fixture/compile/generic_covariant_immutable_constructor/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/generic_covariant_immutable_constructor/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -135,7 +136,8 @@ public function testCovariantPrivatePropertyStoresRealTypeAndIsRuntimeChecked(): self::assertStringNotContainsString('final class', $combined); $fixture->registerAutoload('App\\CovariantPrivateProperty'); - require __DIR__ . '/../../fixture/compile/generic_covariant_private_property/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/generic_covariant_private_property/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -181,7 +183,8 @@ public function testCrossTemplateGenericArgUpcastEmitsEdgeAndRunsAtRuntime(): vo ); $fixture->registerAutoload('App\\'); - require __DIR__ . '/../../fixture/compile/cross_template_generic_arg_upcast/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/cross_template_generic_arg_upcast/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } @@ -202,7 +205,8 @@ public function testComparatorParamOnCovariantClassCompilesAndRunsUnderUpcast(): ); try { $fixture->registerAutoload('App\\'); - require __DIR__ . '/../../fixture/compile/comparator_param_covariant_upcast/verify/runtime.php'; + $runtime = require __DIR__ . '/../../fixture/compile/comparator_param_covariant_upcast/verify/runtime.php'; + $runtime($fixture); } finally { $fixture->cleanup(); } diff --git a/test/fixture/compile/array_sugar/verify/nullable_return.php b/test/fixture/compile/array_sugar/verify/nullable_return.php index 874a4622..533458f6 100644 --- a/test/fixture/compile/array_sugar/verify/nullable_return.php +++ b/test/fixture/compile/array_sugar/verify/nullable_return.php @@ -15,29 +15,32 @@ use PHPUnit\Framework\Assert; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; -$collectionFqn = Registry::generatedFqn( - 'App\\ArraySugar\\Containers\\Collection', - [new TypeRef('App\\ArraySugar\\Models\\User')], -); +return function (CompiledFixture $fixture): void { + $collectionFqn = Registry::generatedFqn( + 'App\\ArraySugar\\Containers\\Collection', + [new TypeRef('App\\ArraySugar\\Models\\User')], + ); -// Non-empty: first() returns the concrete typed instance, all() is an array. -$collection = new $collectionFqn( - new \App\ArraySugar\Models\User('alice'), - new \App\ArraySugar\Models\User('bob'), -); -Assert::assertInstanceOf(\App\ArraySugar\Models\User::class, $collection->first()); -$all = $collection->all(); -Assert::assertIsArray($all); -Assert::assertCount(2, $all); + // Non-empty: first() returns the concrete typed instance, all() is an array. + $collection = new $collectionFqn( + new \App\ArraySugar\Models\User('alice'), + new \App\ArraySugar\Models\User('bob'), + ); + Assert::assertInstanceOf(\App\ArraySugar\Models\User::class, $collection->first()); + $all = $collection->all(); + Assert::assertIsArray($all); + Assert::assertCount(2, $all); -// Empty: first() returns null (the nullable arm of `?T`). -$empty = new $collectionFqn(); -Assert::assertNull($empty->first()); + // Empty: first() returns null (the nullable arm of `?T`). + $empty = new $collectionFqn(); + Assert::assertNull($empty->first()); -// Reflection: the `?T` return type must lower to the concrete class, -// not survive as a literal `T` or be widened to `mixed`. -$returnType = (new \ReflectionMethod($collectionFqn, 'first'))->getReturnType(); -Assert::assertInstanceOf(\ReflectionNamedType::class, $returnType); -Assert::assertSame('App\\ArraySugar\\Models\\User', $returnType->getName()); -Assert::assertTrue($returnType->allowsNull()); + // Reflection: the `?T` return type must lower to the concrete class, + // not survive as a literal `T` or be widened to `mixed`. + $returnType = (new \ReflectionMethod($collectionFqn, 'first'))->getReturnType(); + Assert::assertInstanceOf(\ReflectionNamedType::class, $returnType); + Assert::assertSame('App\\ArraySugar\\Models\\User', $returnType->getName()); + Assert::assertTrue($returnType->allowsNull()); +}; diff --git a/test/fixture/compile/array_sugar_before_generic_closure/verify/runtime.php b/test/fixture/compile/array_sugar_before_generic_closure/verify/runtime.php index 64bf4823..d73c7297 100644 --- a/test/fixture/compile/array_sugar_before_generic_closure/verify/runtime.php +++ b/test/fixture/compile/array_sugar_before_generic_closure/verify/runtime.php @@ -7,13 +7,16 @@ * `VeryLongRecordName[]` rewrite shortens the file before the generic * closure; the closure must still specialize and route both calls. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(1, $n); -Assert::assertSame(42, $a); -Assert::assertSame('hello', $b); + Assert::assertSame(1, $n); + Assert::assertSame(42, $a); + Assert::assertSame('hello', $b); +}; diff --git a/test/fixture/compile/arrow_capture_at_declaration/verify/runtime.php b/test/fixture/compile/arrow_capture_at_declaration/verify/runtime.php index f88734c7..f36892cd 100644 --- a/test/fixture/compile/arrow_capture_at_declaration/verify/runtime.php +++ b/test/fixture/compile/arrow_capture_at_declaration/verify/runtime.php @@ -3,8 +3,11 @@ declare(strict_types=1); use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(43, $result); -Assert::assertSame(2, $y); + Assert::assertSame(43, $result); + Assert::assertSame(2, $y); +}; diff --git a/test/fixture/compile/arrow_capture_shadowing/verify/runtime.php b/test/fixture/compile/arrow_capture_shadowing/verify/runtime.php index 2d40a161..5925b0eb 100644 --- a/test/fixture/compile/arrow_capture_shadowing/verify/runtime.php +++ b/test/fixture/compile/arrow_capture_shadowing/verify/runtime.php @@ -3,7 +3,10 @@ declare(strict_types=1); use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(7, $result); + Assert::assertSame(7, $result); +}; diff --git a/test/fixture/compile/arrow_defaults_single/verify/runtime.php b/test/fixture/compile/arrow_defaults_single/verify/runtime.php index f632a917..07e680fa 100644 --- a/test/fixture/compile/arrow_defaults_single/verify/runtime.php +++ b/test/fixture/compile/arrow_defaults_single/verify/runtime.php @@ -5,11 +5,14 @@ /** * Runtime verify for `arrow_defaults_single`. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(7, $r); + Assert::assertSame(7, $r); +}; diff --git a/test/fixture/compile/arrow_defaults_with_implicit_capture/verify/runtime.php b/test/fixture/compile/arrow_defaults_with_implicit_capture/verify/runtime.php index 517bb992..c165556e 100644 --- a/test/fixture/compile/arrow_defaults_with_implicit_capture/verify/runtime.php +++ b/test/fixture/compile/arrow_defaults_with_implicit_capture/verify/runtime.php @@ -5,11 +5,14 @@ /** * Runtime verify for `arrow_defaults_with_implicit_capture`. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(13, $r); + Assert::assertSame(13, $r); +}; diff --git a/test/fixture/compile/arrow_multiple_arg_tuples/verify/runtime.php b/test/fixture/compile/arrow_multiple_arg_tuples/verify/runtime.php index 59c387b8..01255936 100644 --- a/test/fixture/compile/arrow_multiple_arg_tuples/verify/runtime.php +++ b/test/fixture/compile/arrow_multiple_arg_tuples/verify/runtime.php @@ -3,8 +3,11 @@ declare(strict_types=1); use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(10, $a); -Assert::assertSame('hi', $b); + Assert::assertSame(10, $a); + Assert::assertSame('hi', $b); +}; diff --git a/test/fixture/compile/arrow_multiple_captures/verify/runtime.php b/test/fixture/compile/arrow_multiple_captures/verify/runtime.php index 8cb0eabd..dce850ef 100644 --- a/test/fixture/compile/arrow_multiple_captures/verify/runtime.php +++ b/test/fixture/compile/arrow_multiple_captures/verify/runtime.php @@ -3,7 +3,10 @@ declare(strict_types=1); use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(31, $result); + Assert::assertSame(31, $result); +}; diff --git a/test/fixture/compile/arrow_no_captures/verify/runtime.php b/test/fixture/compile/arrow_no_captures/verify/runtime.php index 1fcaec8a..b4eeb2c3 100644 --- a/test/fixture/compile/arrow_no_captures/verify/runtime.php +++ b/test/fixture/compile/arrow_no_captures/verify/runtime.php @@ -3,7 +3,10 @@ declare(strict_types=1); use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(42, $result); + Assert::assertSame(42, $result); +}; diff --git a/test/fixture/compile/arrow_reserved_args_capture/verify/runtime.php b/test/fixture/compile/arrow_reserved_args_capture/verify/runtime.php index 1b805658..e55a0e21 100644 --- a/test/fixture/compile/arrow_reserved_args_capture/verify/runtime.php +++ b/test/fixture/compile/arrow_reserved_args_capture/verify/runtime.php @@ -3,7 +3,10 @@ declare(strict_types=1); use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(203, $result); + Assert::assertSame(203, $result); +}; diff --git a/test/fixture/compile/arrow_reserved_tag_capture/verify/runtime.php b/test/fixture/compile/arrow_reserved_tag_capture/verify/runtime.php index ff9b10d0..470ddc13 100644 --- a/test/fixture/compile/arrow_reserved_tag_capture/verify/runtime.php +++ b/test/fixture/compile/arrow_reserved_tag_capture/verify/runtime.php @@ -3,7 +3,10 @@ declare(strict_types=1); use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(105, $result); + Assert::assertSame(105, $result); +}; diff --git a/test/fixture/compile/attributed_generic_closures/verify/runtime.php b/test/fixture/compile/attributed_generic_closures/verify/runtime.php index 31f78054..5cec73bf 100644 --- a/test/fixture/compile/attributed_generic_closures/verify/runtime.php +++ b/test/fixture/compile/attributed_generic_closures/verify/runtime.php @@ -7,15 +7,18 @@ * static generic closures and arrows must specialize (not silently keep * raw type-param hints), and the emitted program must execute. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(7, $a); -Assert::assertSame('b', $b); -Assert::assertSame(3, $c); -Assert::assertSame('d', $d); -Assert::assertSame(11, $e); + Assert::assertSame(7, $a); + Assert::assertSame('b', $b); + Assert::assertSame(3, $c); + Assert::assertSame('d', $d); + Assert::assertSame(11, $e); +}; diff --git a/test/fixture/compile/bare_new_self_in_generic_body/verify/runtime.php b/test/fixture/compile/bare_new_self_in_generic_body/verify/runtime.php index f4cf1541..ee37a85d 100644 --- a/test/fixture/compile/bare_new_self_in_generic_body/verify/runtime.php +++ b/test/fixture/compile/bare_new_self_in_generic_body/verify/runtime.php @@ -7,13 +7,16 @@ * non-defaults generic body compiles (not rejected by the WI-06 bare-new guard) and * runs -- `$c` is a copy of `$n` produced by `new self`, carrying the same value. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoloader registered. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoloader registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(5, $n->v); -Assert::assertSame(5, $c->v); -Assert::assertNotSame($n, $c); + Assert::assertSame(5, $n->v); + Assert::assertSame(5, $c->v); + Assert::assertNotSame($n, $c); +}; diff --git a/test/fixture/compile/builtin_interface_via_use/verify/runtime.php b/test/fixture/compile/builtin_interface_via_use/verify/runtime.php index 1348f74f..afb5a256 100644 --- a/test/fixture/compile/builtin_interface_via_use/verify/runtime.php +++ b/test/fixture/compile/builtin_interface_via_use/verify/runtime.php @@ -12,7 +12,7 @@ * "Interface ... not found" the moment the class autoloaded. This file proves * the class now loads and the built-ins resolve to the real global symbols. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered * for `App\BuiltinViaUse\` + the generated namespace. */ @@ -20,56 +20,59 @@ use PHPUnit\Framework\Assert; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; -$bagFqn = Registry::generatedFqn( - 'App\\BuiltinViaUse\\ArrayBag', - [new TypeRef('App\\BuiltinViaUse\\Models\\User')], -); +return function (CompiledFixture $fixture): void { + $bagFqn = Registry::generatedFqn( + 'App\\BuiltinViaUse\\ArrayBag', + [new TypeRef('App\\BuiltinViaUse\\Models\\User')], + ); -// Autoloads without a "not found" error — this is the regression under test. -$bag = new $bagFqn(); + // Autoloads without a "not found" error — this is the regression under test. + $bag = new $bagFqn(); -// `extends Countable, IteratorAggregate` (imported, bare) resolved to the real built-ins. -Assert::assertInstanceOf(\Countable::class, $bag); -Assert::assertInstanceOf(\IteratorAggregate::class, $bag); + // `extends Countable, IteratorAggregate` (imported, bare) resolved to the real built-ins. + Assert::assertInstanceOf(\Countable::class, $bag); + Assert::assertInstanceOf(\IteratorAggregate::class, $bag); -$bag->add(new User('alice')); -$bag->add(new User('bob')); + $bag->add(new User('alice')); + $bag->add(new User('bob')); -// Countable::count() via the bare `\count(...)` call (must have stayed bare). -Assert::assertCount(2, $bag); + // Countable::count() via the bare `\count(...)` call (must have stayed bare). + Assert::assertCount(2, $bag); -// `getIterator(): Traversable { return new ArrayIterator(...); }` — return-type -// hint + `new` of an imported built-in, both relocation-proof now. -$iter = $bag->getIterator(); -Assert::assertInstanceOf(\Traversable::class, $iter); -Assert::assertInstanceOf(\ArrayIterator::class, $iter); -Assert::assertCount(2, $iter); + // `getIterator(): Traversable { return new ArrayIterator(...); }` — return-type + // hint + `new` of an imported built-in, both relocation-proof now. + $iter = $bag->getIterator(); + Assert::assertInstanceOf(\Traversable::class, $iter); + Assert::assertInstanceOf(\ArrayIterator::class, $iter); + Assert::assertCount(2, $iter); -// `first()` happy path returns the concrete substituted element type. -Assert::assertInstanceOf(User::class, $bag->first()); -Assert::assertSame('alice', $bag->first()->name); + // `first()` happy path returns the concrete substituted element type. + Assert::assertInstanceOf(User::class, $bag->first()); + Assert::assertSame('alice', $bag->first()->name); -// `extends AbstractBag` (bare, same-namespace) resolved to the real base class. -Assert::assertInstanceOf(\App\BuiltinViaUse\AbstractBag::class, $bag); -Assert::assertTrue((new $bagFqn())->isEmpty([])); + // `extends AbstractBag` (bare, same-namespace) resolved to the real base class. + Assert::assertInstanceOf(\App\BuiltinViaUse\AbstractBag::class, $bag); + Assert::assertTrue((new $bagFqn())->isEmpty([])); -// Param-typed / instanceof / class-const-fetch positions all load and run. -Assert::assertTrue($bag->accepts(new \App\BuiltinViaUse\Errors\EmptyBagError('probe'))); + // Param-typed / instanceof / class-const-fetch positions all load and run. + Assert::assertTrue($bag->accepts(new \App\BuiltinViaUse\Errors\EmptyBagError('probe'))); -// Closure with an imported return type, nested in a relocated method. -$factory = $bag->makeFactory(); -Assert::assertInstanceOf(\Traversable::class, $factory()); + // Closure with an imported return type, nested in a relocated method. + $factory = $bag->makeFactory(); + Assert::assertInstanceOf(\Traversable::class, $factory()); -// Typed (enum) class constant whose type + value reference a same-namespace class. -Assert::assertSame(\App\BuiltinViaUse\Color::Red, $bag::DEFAULT_COLOR); + // Typed (enum) class constant whose type + value reference a same-namespace class. + Assert::assertSame(\App\BuiltinViaUse\Color::Red, $bag::DEFAULT_COLOR); -// `catch (EmptyBagError $e)` + `new EmptyBagError(...)` path on the empty bag. -$empty = new $bagFqn(); -try { - $empty->first(); - Assert::fail('expected RuntimeException from the empty-bag path'); -} catch (\RuntimeException $e) { - Assert::assertSame('bag is empty', $e->getMessage()); - Assert::assertInstanceOf(\App\BuiltinViaUse\Errors\EmptyBagError::class, $e->getPrevious()); -} + // `catch (EmptyBagError $e)` + `new EmptyBagError(...)` path on the empty bag. + $empty = new $bagFqn(); + try { + $empty->first(); + Assert::fail('expected RuntimeException from the empty-bag path'); + } catch (\RuntimeException $e) { + Assert::assertSame('bag is empty', $e->getMessage()); + Assert::assertInstanceOf(\App\BuiltinViaUse\Errors\EmptyBagError::class, $e->getPrevious()); + } +}; diff --git a/test/fixture/compile/closure_arg_bucket3_runtime/verify/runtime.php b/test/fixture/compile/closure_arg_bucket3_runtime/verify/runtime.php index 99a654c6..d35e4948 100644 --- a/test/fixture/compile/closure_arg_bucket3_runtime/verify/runtime.php +++ b/test/fixture/compile/closure_arg_bucket3_runtime/verify/runtime.php @@ -9,11 +9,14 @@ * whose `Closure(E): string` target grounds to `Closure(int): string` under Box, * conforms, erases to `\Closure`, and runs. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame('value=42', $result, 'the grounded bucket-3 self-call argument ran'); + Assert::assertSame('value=42', $result, 'the grounded bucket-3 self-call argument ran'); +}; diff --git a/test/fixture/compile/closure_arg_free_fn_runtime/verify/runtime.php b/test/fixture/compile/closure_arg_free_fn_runtime/verify/runtime.php index ad4195ac..ba44725d 100644 --- a/test/fixture/compile/closure_arg_free_fn_runtime/verify/runtime.php +++ b/test/fixture/compile/closure_arg_free_fn_runtime/verify/runtime.php @@ -8,11 +8,14 @@ * A conforming closure literal passed to a grounded `Closure(int $x): int` generic * free-function parameter compiles, erases to `\Closure`, and runs. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(42, $result, 'the conforming free-function closure argument ran'); + Assert::assertSame(42, $result, 'the conforming free-function closure argument ran'); +}; diff --git a/test/fixture/compile/closure_arg_instance_runtime/verify/runtime.php b/test/fixture/compile/closure_arg_instance_runtime/verify/runtime.php index 4fcc4c88..54dc601e 100644 --- a/test/fixture/compile/closure_arg_instance_runtime/verify/runtime.php +++ b/test/fixture/compile/closure_arg_instance_runtime/verify/runtime.php @@ -8,11 +8,14 @@ * A conforming closure literal passed as a call argument to a grounded * `Closure(Book $x): string` parameter compiles, erases to `\Closure`, and runs. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame('PHP', $result, 'the conforming closure argument ran'); + Assert::assertSame('PHP', $result, 'the conforming closure argument ran'); +}; diff --git a/test/fixture/compile/closure_arg_plain_fn_runtime/verify/runtime.php b/test/fixture/compile/closure_arg_plain_fn_runtime/verify/runtime.php index cb0f8d44..cd4ea546 100644 --- a/test/fixture/compile/closure_arg_plain_fn_runtime/verify/runtime.php +++ b/test/fixture/compile/closure_arg_plain_fn_runtime/verify/runtime.php @@ -8,11 +8,14 @@ * A conforming closure literal passed to a NON-generic free function's * Closure(Book): string parameter compiles, erases to `\Closure`, and runs. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame('PHP', $result, 'the conforming free-function closure argument ran'); + Assert::assertSame('PHP', $result, 'the conforming free-function closure argument ran'); +}; diff --git a/test/fixture/compile/closure_arg_plain_runtime/verify/runtime.php b/test/fixture/compile/closure_arg_plain_runtime/verify/runtime.php index cae159c3..d6acd654 100644 --- a/test/fixture/compile/closure_arg_plain_runtime/verify/runtime.php +++ b/test/fixture/compile/closure_arg_plain_runtime/verify/runtime.php @@ -8,11 +8,14 @@ * A conforming closure literal passed to a NON-generic method's Closure(Book): string * parameter compiles, erases to `\Closure`, and runs. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame('PHP', $result, 'the conforming plain-method closure argument ran'); + Assert::assertSame('PHP', $result, 'the conforming plain-method closure argument ran'); +}; diff --git a/test/fixture/compile/closure_arg_static_runtime/verify/runtime.php b/test/fixture/compile/closure_arg_static_runtime/verify/runtime.php index 385d6473..d395249c 100644 --- a/test/fixture/compile/closure_arg_static_runtime/verify/runtime.php +++ b/test/fixture/compile/closure_arg_static_runtime/verify/runtime.php @@ -8,11 +8,14 @@ * A conforming closure literal passed to a grounded `Closure(int $x): int` * static-method parameter compiles, erases to `\Closure`, and runs. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(42, $result, 'the conforming static closure argument ran'); + Assert::assertSame(42, $result, 'the conforming static closure argument ran'); +}; diff --git a/test/fixture/compile/closure_conformance_array_sugar_runtime/verify/runtime.php b/test/fixture/compile/closure_conformance_array_sugar_runtime/verify/runtime.php index 554aa70a..043c0a0c 100644 --- a/test/fixture/compile/closure_conformance_array_sugar_runtime/verify/runtime.php +++ b/test/fixture/compile/closure_conformance_array_sugar_runtime/verify/runtime.php @@ -8,20 +8,23 @@ * Array-sugar leaves in signature parameter and return positions lower to * `array`, the signatures erase, and the compiled output executes. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(42, $sum, 'sugared-parameter factory closure ran'); -Assert::assertSame([40, 2], $list, 'sugared-return factory closure ran'); + Assert::assertSame(42, $sum, 'sugared-parameter factory closure ran'); + Assert::assertSame([40, 2], $list, 'sugared-return factory closure ran'); -// Full erasure: no bracket-pair residue may survive into the emitted output -// (the trailing `[40, 2]` array literal is body code, not a type). -$emitted = file_get_contents($fixture->targetDir . '/Use.php'); -Assert::assertIsString($emitted); -Assert::assertStringContainsString('\\Closure', $emitted); -Assert::assertStringNotContainsString('Item[]', $emitted); -Assert::assertStringNotContainsString('int[]', $emitted); + // Full erasure: no bracket-pair residue may survive into the emitted output + // (the trailing `[40, 2]` array literal is body code, not a type). + $emitted = file_get_contents($fixture->targetDir . '/Use.php'); + Assert::assertIsString($emitted); + Assert::assertStringContainsString('\\Closure', $emitted); + Assert::assertStringNotContainsString('Item[]', $emitted); + Assert::assertStringNotContainsString('int[]', $emitted); +}; diff --git a/test/fixture/compile/closure_conformance_builtin_ok/verify/runtime.php b/test/fixture/compile/closure_conformance_builtin_ok/verify/runtime.php index bb7d61c9..a93ff27c 100644 --- a/test/fixture/compile/closure_conformance_builtin_ok/verify/runtime.php +++ b/test/fixture/compile/closure_conformance_builtin_ok/verify/runtime.php @@ -8,12 +8,15 @@ * The exception factory compiles (no false-reject against the built-in * `\Throwable` target), erases to `\Closure`, and runs. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertInstanceOf(\Throwable::class, $thrown, 'the factory returned a Throwable'); -Assert::assertSame('boom', $thrown->getMessage()); + Assert::assertInstanceOf(\Throwable::class, $thrown, 'the factory returned a Throwable'); + Assert::assertSame('boom', $thrown->getMessage()); +}; diff --git a/test/fixture/compile/closure_conformance_dnf_runtime/verify/runtime.php b/test/fixture/compile/closure_conformance_dnf_runtime/verify/runtime.php index 46d96184..eb9f2c9f 100644 --- a/test/fixture/compile/closure_conformance_dnf_runtime/verify/runtime.php +++ b/test/fixture/compile/closure_conformance_dnf_runtime/verify/runtime.php @@ -8,19 +8,22 @@ * DNF-grouped signature types scan as one gradual leaf, the signatures erase, * and the compiled output executes. Asserts both factories' closures ran. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(42, $count, 'DNF-parameter factory closure ran'); -Assert::assertSame('both', $tag, 'DNF-return factory closure ran'); + Assert::assertSame(42, $count, 'DNF-parameter factory closure ran'); + Assert::assertSame('both', $tag, 'DNF-return factory closure ran'); -// Full erasure: no DNF residue may survive into the emitted output. -$emitted = file_get_contents($fixture->targetDir . '/Use.php'); -Assert::assertIsString($emitted); -Assert::assertStringContainsString('\\Closure', $emitted); -Assert::assertStringNotContainsString('(Tagged&Counted)', $emitted); -Assert::assertStringNotContainsString('|(', $emitted); + // Full erasure: no DNF residue may survive into the emitted output. + $emitted = file_get_contents($fixture->targetDir . '/Use.php'); + Assert::assertIsString($emitted); + Assert::assertStringContainsString('\\Closure', $emitted); + Assert::assertStringNotContainsString('(Tagged&Counted)', $emitted); + Assert::assertStringNotContainsString('|(', $emitted); +}; diff --git a/test/fixture/compile/closure_conformance_grounded_runtime/verify/runtime.php b/test/fixture/compile/closure_conformance_grounded_runtime/verify/runtime.php index cd10a58d..32fb5102 100644 --- a/test/fixture/compile/closure_conformance_grounded_runtime/verify/runtime.php +++ b/test/fixture/compile/closure_conformance_grounded_runtime/verify/runtime.php @@ -8,11 +8,14 @@ * The type parameter grounds to `int`; the conforming factory closure compiles, * its `Closure(...)` target erases to `\Closure`, and it runs. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(42, $result, 'the grounded factory closure ran'); + Assert::assertSame(42, $result, 'the grounded factory closure ran'); +}; diff --git a/test/fixture/compile/closure_conformance_runtime/verify/runtime.php b/test/fixture/compile/closure_conformance_runtime/verify/runtime.php index d1173436..7ff315b1 100644 --- a/test/fixture/compile/closure_conformance_runtime/verify/runtime.php +++ b/test/fixture/compile/closure_conformance_runtime/verify/runtime.php @@ -9,20 +9,23 @@ * compiles clean and the erased-to-`\Closure` output executes. Asserts the * factories' closures actually ran with the values they were built from. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(42, $scaled, 'stored property closure ran'); -Assert::assertSame(42, $added, 'method-return factory closure ran'); -Assert::assertSame(42, $incremented, 'free-function factory closure ran'); -Assert::assertSame(42, $tripled, 'arrow-body factory closure ran'); + Assert::assertSame(42, $scaled, 'stored property closure ran'); + Assert::assertSame(42, $added, 'method-return factory closure ran'); + Assert::assertSame(42, $incremented, 'free-function factory closure ran'); + Assert::assertSame(42, $tripled, 'arrow-body factory closure ran'); -// The erased output must carry a bare \Closure, never a residual `Closure(int`. -$emitted = file_get_contents($fixture->targetDir . '/Use.php'); -Assert::assertIsString($emitted); -Assert::assertStringContainsString('\\Closure', $emitted); -Assert::assertStringNotContainsString('Closure(int', $emitted); + // The erased output must carry a bare \Closure, never a residual `Closure(int`. + $emitted = file_get_contents($fixture->targetDir . '/Use.php'); + Assert::assertIsString($emitted); + Assert::assertStringContainsString('\\Closure', $emitted); + Assert::assertStringNotContainsString('Closure(int', $emitted); +}; diff --git a/test/fixture/compile/closure_defaults_single/verify/runtime.php b/test/fixture/compile/closure_defaults_single/verify/runtime.php index 1bc0e19f..3704d94b 100644 --- a/test/fixture/compile/closure_defaults_single/verify/runtime.php +++ b/test/fixture/compile/closure_defaults_single/verify/runtime.php @@ -5,11 +5,14 @@ /** * Runtime verify for `closure_defaults_single`. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(42, $r); + Assert::assertSame(42, $r); +}; diff --git a/test/fixture/compile/closure_defaults_with_use/verify/runtime.php b/test/fixture/compile/closure_defaults_with_use/verify/runtime.php index 38f172dd..317acef4 100644 --- a/test/fixture/compile/closure_defaults_with_use/verify/runtime.php +++ b/test/fixture/compile/closure_defaults_with_use/verify/runtime.php @@ -5,11 +5,14 @@ /** * Runtime verify for `closure_defaults_with_use`. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(105, $r); + Assert::assertSame(105, $r); +}; diff --git a/test/fixture/compile/closure_dispatcher_arrow/verify/runtime.php b/test/fixture/compile/closure_dispatcher_arrow/verify/runtime.php index e263db92..ea33094f 100644 --- a/test/fixture/compile/closure_dispatcher_arrow/verify/runtime.php +++ b/test/fixture/compile/closure_dispatcher_arrow/verify/runtime.php @@ -5,7 +5,7 @@ /** * Runtime verify for `closure_dispatcher_arrow`. * - * Driver contract: `$fixture` (CompiledFixture) must be in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. * Requiring the compiled `Use.php` brings the top-level variables * `$y` and `$resultArrow` into this file's scope, so the assertions * read them directly. The capture moment is the assign site, so the @@ -13,8 +13,11 @@ */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(43, $resultArrow); -Assert::assertSame(2, $y); + Assert::assertSame(43, $resultArrow); + Assert::assertSame(2, $y); +}; diff --git a/test/fixture/compile/closure_dispatcher_defaults/verify/runtime.php b/test/fixture/compile/closure_dispatcher_defaults/verify/runtime.php index a41d4640..fd181031 100644 --- a/test/fixture/compile/closure_dispatcher_defaults/verify/runtime.php +++ b/test/fixture/compile/closure_dispatcher_defaults/verify/runtime.php @@ -5,16 +5,19 @@ /** * Runtime verify for `closure_dispatcher_defaults`. * - * Driver contract: `$fixture` (CompiledFixture) must be in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. * After requiring the compiled `Use.php`, the empty-turbofish and * explicit calls leave their results in `$resultPaddedClosure`, * `$resultExplicitClosure`, and `$resultPaddedArrow`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame('#42', $resultPaddedClosure); -Assert::assertSame('#hi', $resultExplicitClosure); -Assert::assertSame('world', $resultPaddedArrow); + Assert::assertSame('#42', $resultPaddedClosure); + Assert::assertSame('#hi', $resultExplicitClosure); + Assert::assertSame('world', $resultPaddedArrow); +}; diff --git a/test/fixture/compile/closure_dispatcher_runtime_routing/verify/runtime.php b/test/fixture/compile/closure_dispatcher_runtime_routing/verify/runtime.php index 7d7824db..b4299154 100644 --- a/test/fixture/compile/closure_dispatcher_runtime_routing/verify/runtime.php +++ b/test/fixture/compile/closure_dispatcher_runtime_routing/verify/runtime.php @@ -7,12 +7,15 @@ * requiring the compiled Use.php, the two top-level vars `$a` and * `$b` hold the values routed through the dispatcher arms. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(42, $a); -Assert::assertSame('hello', $b); + Assert::assertSame(42, $a); + Assert::assertSame('hello', $b); +}; diff --git a/test/fixture/compile/closure_dispatcher_unknown_tag/verify/runtime.php b/test/fixture/compile/closure_dispatcher_unknown_tag/verify/runtime.php index 45261a62..eb7bf490 100644 --- a/test/fixture/compile/closure_dispatcher_unknown_tag/verify/runtime.php +++ b/test/fixture/compile/closure_dispatcher_unknown_tag/verify/runtime.php @@ -9,18 +9,21 @@ * closure) at top level; the verify file then invokes it with a * tag that no real call site emits. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -$caught = null; -try { - $id('T_bogus', 99); -} catch (\RuntimeException $e) { - $caught = $e; -} -Assert::assertInstanceOf(\RuntimeException::class, $caught); -Assert::assertSame('Unknown generic specialization tag: T_bogus', $caught->getMessage()); + $caught = null; + try { + $id('T_bogus', 99); + } catch (\RuntimeException $e) { + $caught = $e; + } + Assert::assertInstanceOf(\RuntimeException::class, $caught); + Assert::assertSame('Unknown generic specialization tag: T_bogus', $caught->getMessage()); +}; diff --git a/test/fixture/compile/closure_dispatcher_use_clause/verify/runtime.php b/test/fixture/compile/closure_dispatcher_use_clause/verify/runtime.php index 060d9d99..c576740a 100644 --- a/test/fixture/compile/closure_dispatcher_use_clause/verify/runtime.php +++ b/test/fixture/compile/closure_dispatcher_use_clause/verify/runtime.php @@ -5,7 +5,7 @@ /** * Runtime verify for `closure_dispatcher_use_clause`. * - * Driver contract: `$fixture` (CompiledFixture) must be in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. * The compiled `Use.php` defines `$base`, `$counter`, and three call * results `$callA`, `$callB`, `$callC` at top level. After require, * each is available here. The by-ref `&$counter` capture mutates @@ -13,10 +13,13 @@ */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame([1, 10, 1], $callA); -Assert::assertSame([2, 10, 2], $callB); -Assert::assertSame(['hi', 10, 3], $callC); -Assert::assertSame(3, $counter); + Assert::assertSame([1, 10, 1], $callA); + Assert::assertSame([2, 10, 2], $callB); + Assert::assertSame(['hi', 10, 3], $callC); + Assert::assertSame(3, $counter); +}; diff --git a/test/fixture/compile/closure_named_user_function_runtime/verify/runtime.php b/test/fixture/compile/closure_named_user_function_runtime/verify/runtime.php index 08f270d9..017bc3ef 100644 --- a/test/fixture/compile/closure_named_user_function_runtime/verify/runtime.php +++ b/test/fixture/compile/closure_named_user_function_runtime/verify/runtime.php @@ -8,19 +8,22 @@ * The user function named `Closure` is CALLED (not erased) in every * expression position that shares a `) :` token pair with return-type slots. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(42, $ternary, 'ternary-else call to App\\...\\Closure() executed'); -Assert::assertSame(20, $alt, 'alt-syntax-if call executed'); -Assert::assertSame(12, $case, 'case-label call executed'); + Assert::assertSame(42, $ternary, 'ternary-else call to App\\...\\Closure() executed'); + Assert::assertSame(20, $alt, 'alt-syntax-if call executed'); + Assert::assertSame(12, $case, 'case-label call executed'); -// The calls survive verbatim — no bare `\Closure` constant fetch anywhere. -$emitted = file_get_contents($fixture->targetDir . '/Use.php'); -Assert::assertIsString($emitted); -Assert::assertStringNotContainsString('\\Closure ', $emitted); -Assert::assertStringContainsString('Closure(HALF)', $emitted); + // The calls survive verbatim — no bare `\Closure` constant fetch anywhere. + $emitted = file_get_contents($fixture->targetDir . '/Use.php'); + Assert::assertIsString($emitted); + Assert::assertStringNotContainsString('\\Closure ', $emitted); + Assert::assertStringContainsString('Closure(HALF)', $emitted); +}; diff --git a/test/fixture/compile/closure_use_by_ref/verify/runtime.php b/test/fixture/compile/closure_use_by_ref/verify/runtime.php index df315e2b..4c7e7d9f 100644 --- a/test/fixture/compile/closure_use_by_ref/verify/runtime.php +++ b/test/fixture/compile/closure_use_by_ref/verify/runtime.php @@ -5,12 +5,15 @@ /** * Runtime verify for `closure_use_by_ref`. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(99, $a); -Assert::assertSame(99, $y); + Assert::assertSame(99, $a); + Assert::assertSame(99, $y); +}; diff --git a/test/fixture/compile/closure_use_by_value/verify/runtime.php b/test/fixture/compile/closure_use_by_value/verify/runtime.php index 811b85d4..0150ebfe 100644 --- a/test/fixture/compile/closure_use_by_value/verify/runtime.php +++ b/test/fixture/compile/closure_use_by_value/verify/runtime.php @@ -5,12 +5,15 @@ /** * Runtime verify for `closure_use_by_value`. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(43, $result); -Assert::assertSame(2, $y); + Assert::assertSame(43, $result); + Assert::assertSame(2, $y); +}; diff --git a/test/fixture/compile/closure_use_capture_named_xphp_args/verify/runtime.php b/test/fixture/compile/closure_use_capture_named_xphp_args/verify/runtime.php index a5c8cda3..4d9415c6 100644 --- a/test/fixture/compile/closure_use_capture_named_xphp_args/verify/runtime.php +++ b/test/fixture/compile/closure_use_capture_named_xphp_args/verify/runtime.php @@ -5,11 +5,14 @@ /** * Runtime verify for `closure_use_capture_named_xphp_args`. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(203, $r); + Assert::assertSame(203, $r); +}; diff --git a/test/fixture/compile/closure_use_multiple_arg_tuples/verify/runtime.php b/test/fixture/compile/closure_use_multiple_arg_tuples/verify/runtime.php index 3f3fec7e..026b1e10 100644 --- a/test/fixture/compile/closure_use_multiple_arg_tuples/verify/runtime.php +++ b/test/fixture/compile/closure_use_multiple_arg_tuples/verify/runtime.php @@ -5,12 +5,15 @@ /** * Runtime verify for `closure_use_multiple_arg_tuples`. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame('pre:1', $a); -Assert::assertSame('pre:two', $b); + Assert::assertSame('pre:1', $a); + Assert::assertSame('pre:two', $b); +}; diff --git a/test/fixture/compile/closure_use_multiple_mixed_captures/verify/runtime.php b/test/fixture/compile/closure_use_multiple_mixed_captures/verify/runtime.php index 64217bb5..93fc0bbe 100644 --- a/test/fixture/compile/closure_use_multiple_mixed_captures/verify/runtime.php +++ b/test/fixture/compile/closure_use_multiple_mixed_captures/verify/runtime.php @@ -5,13 +5,16 @@ /** * Runtime verify for `closure_use_multiple_mixed_captures`. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(36, $r); -Assert::assertSame(10, $a); -Assert::assertSame(25, $b); + Assert::assertSame(36, $r); + Assert::assertSame(10, $a); + Assert::assertSame(25, $b); +}; diff --git a/test/fixture/compile/comparator_param_covariant_upcast/verify/runtime.php b/test/fixture/compile/comparator_param_covariant_upcast/verify/runtime.php index 4141f308..bdda7bbc 100644 --- a/test/fixture/compile/comparator_param_covariant_upcast/verify/runtime.php +++ b/test/fixture/compile/comparator_param_covariant_upcast/verify/runtime.php @@ -15,13 +15,15 @@ * 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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + 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"; + 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'); +}; diff --git a/test/fixture/compile/covariant_gapfill_free_symbol/verify/runtime.php b/test/fixture/compile/covariant_gapfill_free_symbol/verify/runtime.php index 7dbef1cc..20fea719 100644 --- a/test/fixture/compile/covariant_gapfill_free_symbol/verify/runtime.php +++ b/test/fixture/compile/covariant_gapfill_free_symbol/verify/runtime.php @@ -10,14 +10,16 @@ * upcast `indexOf` (via Bag) returns -OFFSET + tally(3) = 1 for the absent tuple. A bare reference that * rebinds to the generated namespace would fatal ("undefined function XPHP\Generated\App\Lst\tally"). * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -// Free functions/consts aren't autoloadable, so define them before Use.php runs. -require $fixture->targetDir . '/helpers.php'; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + // Free functions/consts aren't autoloadable, so define them before Use.php runs. + require $fixture->targetDir . '/helpers.php'; + require $fixture->targetDir . '/Use.php'; -Assert::assertTrue($found, 'the upcast contains-call (via Lst) must bind the App free symbols and report the tuple absent'); -Assert::assertSame(1, $index, 'the upcast indexOf-call (via Bag) must compute -OFFSET + tally(3) = 1 for the absent tuple'); -echo "OK\n"; + Assert::assertTrue($found, 'the upcast contains-call (via Lst) must bind the App free symbols and report the tuple absent'); + Assert::assertSame(1, $index, 'the upcast indexOf-call (via Bag) must compute -OFFSET + tally(3) = 1 for the absent tuple'); +}; diff --git a/test/fixture/compile/covariant_upcast_multipath_diamond/verify/runtime.php b/test/fixture/compile/covariant_upcast_multipath_diamond/verify/runtime.php index be7282d5..e5296d9b 100644 --- a/test/fixture/compile/covariant_upcast_multipath_diamond/verify/runtime.php +++ b/test/fixture/compile/covariant_upcast_multipath_diamond/verify/runtime.php @@ -10,10 +10,13 @@ * `contains` (via Lst) and an upcast `indexOf` (via Bag) both resolve and run proves the gap-fill supplies * every diamond sibling across interfaces and concretes, independent of discovery order. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; -require $fixture->targetDir . '/Use.php'; -Assert::assertFalse($found, 'the upcast contains-call (via Lst) must resolve, run, and report the tuple absent'); -Assert::assertSame(-1, $index, 'the upcast indexOf-call (via Bag) must resolve, run, and report the tuple absent'); -echo "OK\n"; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; + Assert::assertFalse($found, 'the upcast contains-call (via Lst) must resolve, run, and report the tuple absent'); + Assert::assertSame(-1, $index, 'the upcast indexOf-call (via Bag) must resolve, run, and report the tuple absent'); +}; diff --git a/test/fixture/compile/covariant_upcast_nested_generic_diamond/verify/runtime.php b/test/fixture/compile/covariant_upcast_nested_generic_diamond/verify/runtime.php index 1d1ad59a..8f0bded4 100644 --- a/test/fixture/compile/covariant_upcast_nested_generic_diamond/verify/runtime.php +++ b/test/fixture/compile/covariant_upcast_nested_generic_diamond/verify/runtime.php @@ -8,9 +8,12 @@ * `contains_>` sibling unimplemented) and fataled at class load. That it loads and * `probe` returns proves the gap-fill supplied every diamond obligation. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; -require $fixture->targetDir . '/Use.php'; -Assert::assertFalse($found, 'the diamond upcast contains-call must resolve, run, and report the tuple absent'); -echo "OK\n"; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; + Assert::assertFalse($found, 'the diamond upcast contains-call must resolve, run, and report the tuple absent'); +}; diff --git a/test/fixture/compile/covariant_upcast_return_enclosing_inherited/verify/runtime.php b/test/fixture/compile/covariant_upcast_return_enclosing_inherited/verify/runtime.php index 7dd2b266..410988df 100644 --- a/test/fixture/compile/covariant_upcast_return_enclosing_inherited/verify/runtime.php +++ b/test/fixture/compile/covariant_upcast_return_enclosing_inherited/verify/runtime.php @@ -8,9 +8,12 @@ * emission; the post-edge gap-fill must recognise it as already provided and leave it alone. That this * compiles, loads, and `probe` returns the stored Book proves A3 does not over-emit a return-E member. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; -require $fixture->targetDir . '/Use.php'; -Assert::assertInstanceOf(\App\Book::class, $first, 'firstOr returns the stored Book through the covariant upcast'); -echo "OK\n"; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; + Assert::assertInstanceOf(\App\Book::class, $first, 'firstOr returns the stored Book through the covariant upcast'); +}; 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 index ce573390..81d0751f 100644 --- a/test/fixture/compile/cross_template_generic_arg_upcast/verify/runtime.php +++ b/test/fixture/compile/cross_template_generic_arg_upcast/verify/runtime.php @@ -16,12 +16,14 @@ * 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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertInstanceOf(\App\Book::class, $first, 'the upcast tuple resolved and yielded its Book element'); -echo "OK\n"; + Assert::assertInstanceOf(\App\Book::class, $first, 'the upcast tuple resolved and yielded its Book element'); +}; 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 index bec2d846..088b6830 100644 --- a/test/fixture/compile/enclosing_bound_erasure_covariant_chain/verify/runtime.php +++ b/test/fixture/compile/enclosing_bound_erasure_covariant_chain/verify/runtime.php @@ -11,13 +11,16 @@ * 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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + 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'); + 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/verify/runtime.php b/test/fixture/compile/enclosing_bound_erasure_forwarding/verify/runtime.php index aca06612..774d5bb4 100644 --- a/test/fixture/compile/enclosing_bound_erasure_forwarding/verify/runtime.php +++ b/test/fixture/compile/enclosing_bound_erasure_forwarding/verify/runtime.php @@ -11,12 +11,15 @@ * `$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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + 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'); + 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/verify/runtime.php b/test/fixture/compile/enclosing_bound_erasure_inherited/verify/runtime.php index 4fffa588..8c92cb4f 100644 --- a/test/fixture/compile/enclosing_bound_erasure_inherited/verify/runtime.php +++ b/test/fixture/compile/enclosing_bound_erasure_inherited/verify/runtime.php @@ -11,11 +11,14 @@ * 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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertTrue($inherited, 'inherited erasable member must resolve via the same E-mangled name'); + Assert::assertTrue($inherited, 'inherited erasable member must resolve via the same E-mangled name'); +}; 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 index 33ce0c1e..c6d1c5fc 100644 --- a/test/fixture/compile/enclosing_bound_erasure_map_multiparam/verify/runtime.php +++ b/test/fixture/compile/enclosing_bound_erasure_map_multiparam/verify/runtime.php @@ -10,12 +10,15 @@ * 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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertTrue($found, 'containsValue mangled on V (Fruit) must resolve and run'); -Assert::assertSame('k', $label); + 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/verify/runtime.php b/test/fixture/compile/enclosing_bound_erasure_param_widening/verify/runtime.php index bbb51c83..f74a2e3a 100644 --- a/test/fixture/compile/enclosing_bound_erasure_param_widening/verify/runtime.php +++ b/test/fixture/compile/enclosing_bound_erasure_param_widening/verify/runtime.php @@ -10,12 +10,15 @@ * (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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + 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'); + Assert::assertTrue($banana, 'contains:: runs on the widened Fruit-typed member'); + Assert::assertTrue($cherry, 'contains:: runs on the SAME widened member'); +}; 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 index 1b3f6507..d8eda5fc 100644 --- a/test/fixture/compile/enclosing_bound_erasure_two_params/verify/runtime.php +++ b/test/fixture/compile/enclosing_bound_erasure_two_params/verify/runtime.php @@ -9,11 +9,14 @@ * `[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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertTrue($both, 'a two-bounded-param erasable method must resolve and run'); + Assert::assertTrue($both, 'a two-bounded-param erasable method must resolve and run'); +}; diff --git a/test/fixture/compile/enclosing_bound_interface_upcast/verify/runtime.php b/test/fixture/compile/enclosing_bound_interface_upcast/verify/runtime.php index 5c068787..aab27a4a 100644 --- a/test/fixture/compile/enclosing_bound_interface_upcast/verify/runtime.php +++ b/test/fixture/compile/enclosing_bound_interface_upcast/verify/runtime.php @@ -15,12 +15,14 @@ * 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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertFalse($found, 'the upcast contains-call must resolve, run, and report the Product absent'); -echo "OK\n"; + Assert::assertFalse($found, 'the upcast contains-call must resolve, run, and report the Product absent'); +}; 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 index 5cbde100..75c6df57 100644 --- a/test/fixture/compile/enclosing_bound_interface_upcast_map/verify/runtime.php +++ b/test/fixture/compile/enclosing_bound_interface_upcast_map/verify/runtime.php @@ -10,12 +10,14 @@ * — 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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertFalse($found, 'the multi-param upcast containsValue-call must resolve and run'); -echo "OK\n"; + Assert::assertFalse($found, 'the multi-param upcast containsValue-call must resolve and run'); +}; 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 index c0368eba..42b0da70 100644 --- a/test/fixture/compile/enclosing_bound_subinterface_direct_emit/verify/runtime.php +++ b/test/fixture/compile/enclosing_bound_subinterface_direct_emit/verify/runtime.php @@ -11,13 +11,15 @@ * `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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + 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"; + 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'); +}; 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 index 16e5a08c..768958be 100644 --- 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 @@ -11,12 +11,14 @@ * 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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + 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"; + Assert::assertFalse($result, 'the body class parameter E must substitute to the upcast-source concrete (Book), not the supertype (Product)'); +}; diff --git a/test/fixture/compile/free_symbol_group_use_import/verify/runtime.php b/test/fixture/compile/free_symbol_group_use_import/verify/runtime.php index 212ea91d..840bdde8 100644 --- a/test/fixture/compile/free_symbol_group_use_import/verify/runtime.php +++ b/test/fixture/compile/free_symbol_group_use_import/verify/runtime.php @@ -7,12 +7,15 @@ * and `use Vendor\{const RATE, const STEP}` imports in the template resolve to Vendor's symbols, and * the relocated specialization still binds them. make(3)=6 + scale(1)=10 + RATE=100 + STEP=7 = 123. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoloader registered. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoloader registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/lib.php'; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/lib.php'; + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(123, $computed); + Assert::assertSame(123, $computed); +}; diff --git a/test/fixture/compile/free_symbol_requalify/verify/runtime.php b/test/fixture/compile/free_symbol_requalify/verify/runtime.php index a05f1651..688e370b 100644 --- a/test/fixture/compile/free_symbol_requalify/verify/runtime.php +++ b/test/fixture/compile/free_symbol_requalify/verify/runtime.php @@ -8,15 +8,18 @@ * builtin and magic constant left to global resolution. * scale(1)=10, BONUS=5, Sub\tweak(2)=3, strlen('ab')=2, true?0 -> 10+5+3+2+0 = 20 * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoloader registered. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoloader registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -// Free functions/consts aren't autoloadable, so define them before Use.php runs its -// top-level instantiation (the specialized class itself autoloads via PSR-4). -require $fixture->targetDir . '/helpers.php'; -require $fixture->targetDir . '/sub.php'; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + // Free functions/consts aren't autoloadable, so define them before Use.php runs its + // top-level instantiation (the specialized class itself autoloads via PSR-4). + require $fixture->targetDir . '/helpers.php'; + require $fixture->targetDir . '/sub.php'; + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(20, $result); + Assert::assertSame(20, $result); +}; diff --git a/test/fixture/compile/free_symbol_use_import/verify/runtime.php b/test/fixture/compile/free_symbol_use_import/verify/runtime.php index c4a8a686..6cb3c936 100644 --- a/test/fixture/compile/free_symbol_use_import/verify/runtime.php +++ b/test/fixture/compile/free_symbol_use_import/verify/runtime.php @@ -7,12 +7,15 @@ * template resolves to another namespace's symbols, and the relocated specialization still * binds them. make(3)=6, RATE=100 -> 106. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoloader registered. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoloader registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/lib.php'; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/lib.php'; + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(106, $computed); + Assert::assertSame(106, $computed); +}; diff --git a/test/fixture/compile/generic_covariant_immutable_constructor/verify/runtime.php b/test/fixture/compile/generic_covariant_immutable_constructor/verify/runtime.php index 8bac4c9f..c4845803 100644 --- a/test/fixture/compile/generic_covariant_immutable_constructor/verify/runtime.php +++ b/test/fixture/compile/generic_covariant_immutable_constructor/verify/runtime.php @@ -10,7 +10,7 @@ * 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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use App\CovariantConstructor\Banana; @@ -18,26 +18,29 @@ use PHPUnit\Framework\Assert; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(2, $cnt); -Assert::assertSame('banana', $name); + 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\\CovariantConstructor\\ImmutableList', - [new TypeRef('App\\CovariantConstructor\\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'); + // 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\\CovariantConstructor\\ImmutableList', + [new TypeRef('App\\CovariantConstructor\\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); + // And it accepts a real Banana. + $ok = new $bananaListFqn(new Banana()); + Assert::assertSame('banana', $ok->get(0)->name); +}; diff --git a/test/fixture/compile/generic_covariant_private_property/verify/runtime.php b/test/fixture/compile/generic_covariant_private_property/verify/runtime.php index d47479a6..41ecedc9 100644 --- a/test/fixture/compile/generic_covariant_private_property/verify/runtime.php +++ b/test/fixture/compile/generic_covariant_private_property/verify/runtime.php @@ -12,7 +12,7 @@ * (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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use App\CovariantPrivateProperty\Banana; @@ -20,35 +20,38 @@ 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); +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + 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); +}; diff --git a/test/fixture/compile/generic_exception_catch/verify/catch_runtime.php b/test/fixture/compile/generic_exception_catch/verify/catch_runtime.php index c04b177d..e8564e4c 100644 --- a/test/fixture/compile/generic_exception_catch/verify/catch_runtime.php +++ b/test/fixture/compile/generic_exception_catch/verify/catch_runtime.php @@ -7,7 +7,7 @@ * generic specialization (`HttpError` vs `HttpError`) * discriminates on the concrete monomorphized class, so the right arm fires. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered * for `App\GenericExceptionCatch\` + the generated namespace. */ @@ -15,36 +15,39 @@ use PHPUnit\Framework\Assert; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeRef; - -$client = new Client(); - -// Generic type discriminates: the Forbidden throw must skip the textually -// FIRST `HttpError` arm and land on `HttpError`. This is -// the property that would silently break if catch types stopped being -// rewritten to their distinct specialized FQNs. -Assert::assertSame('forbidden:api key revoked', $client->classify('forbidden')); -Assert::assertSame('not-found:missing', $client->classify('notfound')); - -// Bare `catch (HttpError $e)` catches any specialization via the marker -// interface. The caught object is the concrete Forbidden specialization. -$forbiddenFqn = Registry::generatedFqn( - 'App\\GenericExceptionCatch\\Errors\\HttpError', - [new TypeRef('App\\GenericExceptionCatch\\Models\\Forbidden')], -); -Assert::assertSame($forbiddenFqn, $client->catchAny('forbidden')); - -// A union of two specializations matches either thrown error. -Assert::assertSame('union:missing', $client->catchUnion('notfound')); -Assert::assertSame('union:api key revoked', $client->catchUnion('forbidden')); - -// The specialization is a genuine Throwable subtype (so it is catchable at all) -// AND implements the original generic name as a marker interface (so the bare -// catch-all arm above can match it). -Assert::assertTrue( - is_subclass_of($forbiddenFqn, \RuntimeException::class), - 'specialized exception must remain a RuntimeException subtype', -); -Assert::assertTrue( - is_subclass_of($forbiddenFqn, 'App\\GenericExceptionCatch\\Errors\\HttpError'), - 'specialized exception must implement the HttpError marker interface', -); +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + $client = new Client(); + + // Generic type discriminates: the Forbidden throw must skip the textually + // FIRST `HttpError` arm and land on `HttpError`. This is + // the property that would silently break if catch types stopped being + // rewritten to their distinct specialized FQNs. + Assert::assertSame('forbidden:api key revoked', $client->classify('forbidden')); + Assert::assertSame('not-found:missing', $client->classify('notfound')); + + // Bare `catch (HttpError $e)` catches any specialization via the marker + // interface. The caught object is the concrete Forbidden specialization. + $forbiddenFqn = Registry::generatedFqn( + 'App\\GenericExceptionCatch\\Errors\\HttpError', + [new TypeRef('App\\GenericExceptionCatch\\Models\\Forbidden')], + ); + Assert::assertSame($forbiddenFqn, $client->catchAny('forbidden')); + + // A union of two specializations matches either thrown error. + Assert::assertSame('union:missing', $client->catchUnion('notfound')); + Assert::assertSame('union:api key revoked', $client->catchUnion('forbidden')); + + // The specialization is a genuine Throwable subtype (so it is catchable at all) + // AND implements the original generic name as a marker interface (so the bare + // catch-all arm above can match it). + Assert::assertTrue( + is_subclass_of($forbiddenFqn, \RuntimeException::class), + 'specialized exception must remain a RuntimeException subtype', + ); + Assert::assertTrue( + is_subclass_of($forbiddenFqn, 'App\\GenericExceptionCatch\\Errors\\HttpError'), + 'specialized exception must implement the HttpError marker interface', + ); +}; diff --git a/test/fixture/compile/generic_function/verify/runtime_execution.php b/test/fixture/compile/generic_function/verify/runtime_execution.php index 5d4fb479..d5858b29 100644 --- a/test/fixture/compile/generic_function/verify/runtime_execution.php +++ b/test/fixture/compile/generic_function/verify/runtime_execution.php @@ -7,7 +7,7 @@ * `identity_T_` free functions execute and return values of * the substituted concrete types. * - * Driver contract: `$fixture` (CompiledFixture) in scope. Free + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. Free * functions aren't autoloadable in PHP, so require funcs.php * explicitly to bring the specialized declarations into scope. */ @@ -15,15 +15,18 @@ use PHPUnit\Framework\Assert; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/funcs.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/funcs.php'; -$intMangle = 'identity_T_' . Registry::canonicalHash([new TypeRef('int', isScalar: true)]); -$intFqn = 'App\\GenericFunction\\' . $intMangle; -$intResult = $intFqn(42); -Assert::assertSame(42, $intResult); + $intMangle = 'identity_T_' . Registry::canonicalHash([new TypeRef('int', isScalar: true)]); + $intFqn = 'App\\GenericFunction\\' . $intMangle; + $intResult = $intFqn(42); + Assert::assertSame(42, $intResult); -$stringMangle = 'identity_T_' . Registry::canonicalHash([new TypeRef('string', isScalar: true)]); -$stringFqn = 'App\\GenericFunction\\' . $stringMangle; -$stringResult = $stringFqn('hi'); -Assert::assertSame('hi', $stringResult); + $stringMangle = 'identity_T_' . Registry::canonicalHash([new TypeRef('string', isScalar: true)]); + $stringFqn = 'App\\GenericFunction\\' . $stringMangle; + $stringResult = $stringFqn('hi'); + Assert::assertSame('hi', $stringResult); +}; diff --git a/test/fixture/compile/generic_function_bare_top_level/verify/runtime.php b/test/fixture/compile/generic_function_bare_top_level/verify/runtime.php index ceb61746..2f33c0f5 100644 --- a/test/fixture/compile/generic_function_bare_top_level/verify/runtime.php +++ b/test/fixture/compile/generic_function_bare_top_level/verify/runtime.php @@ -7,16 +7,19 @@ * top-level generic function specializes; the non-generic sibling * function survives the strip pass intact; both call sites resolve. * - * Driver contract: `$fixture` (CompiledFixture) in scope. Bare- + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. Bare- * top-level functions aren't autoloadable, so requiring funcs.php * is the only way to bring `identity_T_` and * `nonGenericDouble` into scope. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/funcs.php'; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/funcs.php'; + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(42, $asInt); -Assert::assertSame(42, $doubled); + Assert::assertSame(42, $asInt); + Assert::assertSame(42, $doubled); +}; 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 08875ba5..0b9cbd38 100644 --- a/test/fixture/compile/generic_interface/verify/specialized_interface_runtime.php +++ b/test/fixture/compile/generic_interface/verify/specialized_interface_runtime.php @@ -7,30 +7,33 @@ * implements the specialized interface, and the interface's * `get()` reflection reports the concrete substituted return type. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoload + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload * registered for `App\GenericInterface\` + the generated namespace. */ use PHPUnit\Framework\Assert; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; -$interfaceFqn = Registry::generatedFqn( - 'App\\GenericInterface\\Containers\\Container', - [new TypeRef('App\\GenericInterface\\Models\\Plastic')], -); -$boxFqn = Registry::generatedFqn( - 'App\\GenericInterface\\Containers\\Box', - [new TypeRef('App\\GenericInterface\\Models\\Plastic')], -); +return function (CompiledFixture $fixture): void { + $interfaceFqn = Registry::generatedFqn( + 'App\\GenericInterface\\Containers\\Container', + [new TypeRef('App\\GenericInterface\\Models\\Plastic')], + ); + $boxFqn = Registry::generatedFqn( + 'App\\GenericInterface\\Containers\\Box', + [new TypeRef('App\\GenericInterface\\Models\\Plastic')], + ); -$box = new $boxFqn(new \App\GenericInterface\Models\Plastic('red')); + $box = new $boxFqn(new \App\GenericInterface\Models\Plastic('red')); -Assert::assertInstanceOf($interfaceFqn, $box); -Assert::assertSame('red', $box->get()->color); + 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($interfaceFqn, 'get'))->getReturnType(); -Assert::assertInstanceOf(\ReflectionNamedType::class, $returnType); -Assert::assertSame('App\\GenericInterface\\Models\\Plastic', $returnType->getName()); + // Reflection: the interface's get() return type must be the + // concrete substituted class, not the unspecialized `T`. + $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_method/verify/runtime.php b/test/fixture/compile/generic_method/verify/runtime.php index d3381b25..0464077b 100644 --- a/test/fixture/compile/generic_method/verify/runtime.php +++ b/test/fixture/compile/generic_method/verify/runtime.php @@ -7,24 +7,27 @@ * `Util::identity_T_` static methods round-trip their argument * through the substituted concrete type. * - * Driver contract: `$fixture` (CompiledFixture) in scope. Static + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. Static * methods on user classes load via the registered autoloader. */ use PHPUnit\Framework\Assert; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Util.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Util.php'; -$intMangle = 'identity_T_' . Registry::canonicalHash([new TypeRef('int', isScalar: true)]); -$intCallable = ['App\\GenericMethod\\Util', $intMangle]; -$intResult = $intCallable(42); -Assert::assertSame(42, $intResult); -Assert::assertSame('integer', gettype($intResult)); + $intMangle = 'identity_T_' . Registry::canonicalHash([new TypeRef('int', isScalar: true)]); + $intCallable = ['App\\GenericMethod\\Util', $intMangle]; + $intResult = $intCallable(42); + Assert::assertSame(42, $intResult); + Assert::assertSame('integer', gettype($intResult)); -$stringMangle = 'identity_T_' . Registry::canonicalHash([new TypeRef('string', isScalar: true)]); -$stringCallable = ['App\\GenericMethod\\Util', $stringMangle]; -$stringResult = $stringCallable('hello'); -Assert::assertSame('hello', $stringResult); -Assert::assertSame('string', gettype($stringResult)); + $stringMangle = 'identity_T_' . Registry::canonicalHash([new TypeRef('string', isScalar: true)]); + $stringCallable = ['App\\GenericMethod\\Util', $stringMangle]; + $stringResult = $stringCallable('hello'); + Assert::assertSame('hello', $stringResult); + Assert::assertSame('string', gettype($stringResult)); +}; diff --git a/test/fixture/compile/generic_method_local_variable_receiver/verify/runtime.php b/test/fixture/compile/generic_method_local_variable_receiver/verify/runtime.php index 551a03d1..b76f045b 100644 --- a/test/fixture/compile/generic_method_local_variable_receiver/verify/runtime.php +++ b/test/fixture/compile/generic_method_local_variable_receiver/verify/runtime.php @@ -8,13 +8,16 @@ * `$u->identity::(…)` specialize against Util via the visitor's * lexical-last-write record. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Util.php'; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Util.php'; + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(99, $i); -Assert::assertSame('world', $s); + Assert::assertSame(99, $i); + Assert::assertSame('world', $s); +}; diff --git a/test/fixture/compile/generic_method_new_self_turbofish/verify/runtime.php b/test/fixture/compile/generic_method_new_self_turbofish/verify/runtime.php index 355387c0..c332e26d 100644 --- a/test/fixture/compile/generic_method_new_self_turbofish/verify/runtime.php +++ b/test/fixture/compile/generic_method_new_self_turbofish/verify/runtime.php @@ -9,21 +9,24 @@ * specialized class — so `$a->with(13)` returns a `Container` * with `item = 13`, the same class as `$a`. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Container.php'; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Container.php'; + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(13, $b->item); -Assert::assertSame(get_class($a), get_class($b)); + Assert::assertSame(13, $b->item); + Assert::assertSame(get_class($a), get_class($b)); -$specializedFqn = Registry::generatedFqn( - 'App\\GenericMethodNewSelfTurbofish\\Container', - [new TypeRef('int', isScalar: true)], -); -Assert::assertSame($specializedFqn, get_class($a)); + $specializedFqn = Registry::generatedFqn( + 'App\\GenericMethodNewSelfTurbofish\\Container', + [new TypeRef('int', isScalar: true)], + ); + Assert::assertSame($specializedFqn, get_class($a)); +}; diff --git a/test/fixture/compile/generic_method_new_static_turbofish/verify/runtime.php b/test/fixture/compile/generic_method_new_static_turbofish/verify/runtime.php index 610f6978..d98696af 100644 --- a/test/fixture/compile/generic_method_new_static_turbofish/verify/runtime.php +++ b/test/fixture/compile/generic_method_new_static_turbofish/verify/runtime.php @@ -8,21 +8,24 @@ * binding resolves `static` against the specialized class. With no * subclassing in this fixture, `$a` and `$b` end up in the same class. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Builder.php'; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Builder.php'; + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(2, $b->value); -Assert::assertSame(get_class($a), get_class($b)); + Assert::assertSame(2, $b->value); + Assert::assertSame(get_class($a), get_class($b)); -$specializedFqn = Registry::generatedFqn( - 'App\\GenericMethodNewStaticTurbofish\\Builder', - [new TypeRef('int', isScalar: true)], -); -Assert::assertSame($specializedFqn, get_class($a)); + $specializedFqn = Registry::generatedFqn( + 'App\\GenericMethodNewStaticTurbofish\\Builder', + [new TypeRef('int', isScalar: true)], + ); + Assert::assertSame($specializedFqn, get_class($a)); +}; diff --git a/test/fixture/compile/generic_method_self_with_type_args/verify/runtime.php b/test/fixture/compile/generic_method_self_with_type_args/verify/runtime.php index de51b5b1..bf96a1fd 100644 --- a/test/fixture/compile/generic_method_self_with_type_args/verify/runtime.php +++ b/test/fixture/compile/generic_method_self_with_type_args/verify/runtime.php @@ -8,7 +8,7 @@ * specialization, so `Container::withItem(2)` mutates and * returns `$this` with `item = 2`. * - * Driver contract: `$fixture` (CompiledFixture) in scope. The + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. The * autoloader resolves `App\GenericMethodSelfReturnTypeArgs\Container` * (an interface stub) plus the generated `T_` class. */ @@ -16,16 +16,19 @@ use PHPUnit\Framework\Assert; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Container.php'; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Container.php'; + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(2, $b->item); + Assert::assertSame(2, $b->item); -// The specialized class lives under XPHP\Generated\…\Container\T_. -$specializedFqn = Registry::generatedFqn( - 'App\\GenericMethodSelfReturnTypeArgs\\Container', - [new TypeRef('int', isScalar: true)], -); -Assert::assertTrue(class_exists($specializedFqn)); -Assert::assertInstanceOf($specializedFqn, $b); + // The specialized class lives under XPHP\Generated\…\Container\T_. + $specializedFqn = Registry::generatedFqn( + 'App\\GenericMethodSelfReturnTypeArgs\\Container', + [new TypeRef('int', isScalar: true)], + ); + Assert::assertTrue(class_exists($specializedFqn)); + Assert::assertInstanceOf($specializedFqn, $b); +}; diff --git a/test/fixture/compile/generic_method_this_receiver/verify/runtime.php b/test/fixture/compile/generic_method_this_receiver/verify/runtime.php index 059c40ab..a5159a99 100644 --- a/test/fixture/compile/generic_method_this_receiver/verify/runtime.php +++ b/test/fixture/compile/generic_method_this_receiver/verify/runtime.php @@ -8,13 +8,16 @@ * the enclosing class (no flow analysis needed), so the int and * string call sites each land on their own mangled method. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Util.php'; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Util.php'; + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(42, $i); -Assert::assertSame('hi', $s); + Assert::assertSame(42, $i); + Assert::assertSame('hi', $s); +}; 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 a2759368..d69b7193 100644 --- a/test/fixture/compile/generic_method_through_inheritance/verify/runtime.php +++ b/test/fixture/compile/generic_method_through_inheritance/verify/runtime.php @@ -9,12 +9,15 @@ * 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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame('hi', $s); -Assert::assertSame(7, $n); + Assert::assertSame('hi', $s); + Assert::assertSame(7, $n); +}; 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 6c71aeea..61ebfb99 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 @@ -8,14 +8,17 @@ * 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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Base.php'; -require $fixture->targetDir . '/Derived.php'; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Base.php'; + require $fixture->targetDir . '/Derived.php'; + require $fixture->targetDir . '/Use.php'; -Assert::assertSame('hi', $s); -Assert::assertSame(7, $n); + Assert::assertSame('hi', $s); + Assert::assertSame(7, $n); +}; diff --git a/test/fixture/compile/generic_trait_adaptation/verify/runtime.php b/test/fixture/compile/generic_trait_adaptation/verify/runtime.php index 6a2e7e4b..dc4b451a 100644 --- a/test/fixture/compile/generic_trait_adaptation/verify/runtime.php +++ b/test/fixture/compile/generic_trait_adaptation/verify/runtime.php @@ -11,14 +11,17 @@ * `use`-list entries -- a bare operand would fatal at class load ("Trait App\... not * found"). * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoloader registered. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoloader registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame('A:5', $pick); // insteadof: A's pick wins -Assert::assertSame('B:6', $bpick); // as: B's pick reachable under the alias -Assert::assertSame(3, $aOnly); // A's distinct method -Assert::assertSame(4, $bOnly); // B's distinct method + Assert::assertSame('A:5', $pick); // insteadof: A's pick wins + Assert::assertSame('B:6', $bpick); // as: B's pick reachable under the alias + Assert::assertSame(3, $aOnly); // A's distinct method + Assert::assertSame(4, $bOnly); // B's distinct method +}; diff --git a/test/fixture/compile/generic_trait_adaptation_mixed/verify/runtime.php b/test/fixture/compile/generic_trait_adaptation_mixed/verify/runtime.php index 2580c513..6b6cf79e 100644 --- a/test/fixture/compile/generic_trait_adaptation_mixed/verify/runtime.php +++ b/test/fixture/compile/generic_trait_adaptation_mixed/verify/runtime.php @@ -9,14 +9,17 @@ * generic operands rewrite to their specializations. Plain's `val` wins; the excluded * generic `val`s are re-exposed under aliases; Plain's distinct method runs. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoloader registered. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoloader registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame('P:1', $val); // insteadof: Plain's val wins over G, H -Assert::assertSame('G:2', $gval); // as: excluded generic G::val under alias -Assert::assertSame('H:3', $hval); // as: excluded generic H::val under alias -Assert::assertSame('plain', $plainOnly); // Plain's distinct method + Assert::assertSame('P:1', $val); // insteadof: Plain's val wins over G, H + Assert::assertSame('G:2', $gval); // as: excluded generic G::val under alias + Assert::assertSame('H:3', $hval); // as: excluded generic H::val under alias + Assert::assertSame('plain', $plainOnly); // Plain's distinct method +}; diff --git a/test/fixture/compile/group_import_class_requalify/verify/runtime.php b/test/fixture/compile/group_import_class_requalify/verify/runtime.php index 1c8a7b11..c3393110 100644 --- a/test/fixture/compile/group_import_class_requalify/verify/runtime.php +++ b/test/fixture/compile/group_import_class_requalify/verify/runtime.php @@ -8,15 +8,18 @@ * relocated Box body. Tool::ping()='pong' . Widget::spin()='spin' -> 'pongspin'. A group form * that fell back to the current namespace would fatal with "Class App\Tool not found". * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -// Vendor's two classes share one emitted lib.php (not one-class-per-file), so PSR-4 can't autoload -// them — require it before Use.php runs its top-level instantiation. The specialized Box autoloads -// via XPHP\Generated. -require $fixture->targetDir . '/lib.php'; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + // Vendor's two classes share one emitted lib.php (not one-class-per-file), so PSR-4 can't autoload + // them — require it before Use.php runs its top-level instantiation. The specialized Box autoloads + // via XPHP\Generated. + require $fixture->targetDir . '/lib.php'; + require $fixture->targetDir . '/Use.php'; -Assert::assertSame('pongspin', $result); + Assert::assertSame('pongspin', $result); +}; diff --git a/test/fixture/compile/keyword_named_generic_method/verify/runtime.php b/test/fixture/compile/keyword_named_generic_method/verify/runtime.php index 40284974..200c6ab2 100644 --- a/test/fixture/compile/keyword_named_generic_method/verify/runtime.php +++ b/test/fixture/compile/keyword_named_generic_method/verify/runtime.php @@ -7,12 +7,15 @@ * (`list`, `print`) declares, specializes, and is callable through both an instance turbofish and a * static turbofish. Instance `list::(41)` returns 41; static `print::(7)` returns 7. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(41, $instanceResult); -Assert::assertSame(7, $staticResult); + Assert::assertSame(41, $instanceResult); + Assert::assertSame(7, $staticResult); +}; diff --git a/test/fixture/compile/keyword_nongeneric_passthrough/verify/runtime.php b/test/fixture/compile/keyword_nongeneric_passthrough/verify/runtime.php index 3d9a8cbf..20ed4c75 100644 --- a/test/fixture/compile/keyword_nongeneric_passthrough/verify/runtime.php +++ b/test/fixture/compile/keyword_nongeneric_passthrough/verify/runtime.php @@ -7,13 +7,16 @@ * `list()` destructuring survive the keyword-turbofish support unchanged. list(10)+1=11, * print(20)+2=22, unpack([3,4])=7. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(11, $instance); -Assert::assertSame(22, $static); -Assert::assertSame(7, $destructured); + Assert::assertSame(11, $instance); + Assert::assertSame(22, $static); + Assert::assertSame(7, $destructured); +}; diff --git a/test/fixture/compile/multi_type/verify/type_error_on_wrong_slot.php b/test/fixture/compile/multi_type/verify/type_error_on_wrong_slot.php index 28d3909f..08e08b64 100644 --- a/test/fixture/compile/multi_type/verify/type_error_on_wrong_slot.php +++ b/test/fixture/compile/multi_type/verify/type_error_on_wrong_slot.php @@ -6,35 +6,38 @@ * Runtime verify for `multi_type`: a specialized Pair * accepts (User, Plastic) but rejects (Plastic, User) with a TypeError. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoload + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload * registered for `App\MultiType\` + the generated namespace. */ use PHPUnit\Framework\Assert; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; -$pairFqn = Registry::generatedFqn( - 'App\\MultiType\\Containers\\Pair', - [new TypeRef('App\\MultiType\\Models\\User'), new TypeRef('App\\MultiType\\Models\\Plastic')], -); - -// Correct order: constructor accepts (User, Plastic). -$ok = new $pairFqn( - new \App\MultiType\Models\User('alice'), - new \App\MultiType\Models\Plastic('red'), -); -Assert::assertInstanceOf($pairFqn, $ok); +return function (CompiledFixture $fixture): void { + $pairFqn = Registry::generatedFqn( + 'App\\MultiType\\Containers\\Pair', + [new TypeRef('App\\MultiType\\Models\\User'), new TypeRef('App\\MultiType\\Models\\Plastic')], + ); -// Swapped order: constructor expects User in slot 0, Plastic in slot 1; -// passing them flipped triggers a TypeError on the first slot mismatch. -$caught = null; -try { - new $pairFqn( - new \App\MultiType\Models\Plastic('red'), + // Correct order: constructor accepts (User, Plastic). + $ok = new $pairFqn( new \App\MultiType\Models\User('alice'), + new \App\MultiType\Models\Plastic('red'), ); -} catch (\TypeError $e) { - $caught = $e; -} -Assert::assertInstanceOf(\TypeError::class, $caught); + Assert::assertInstanceOf($pairFqn, $ok); + + // Swapped order: constructor expects User in slot 0, Plastic in slot 1; + // passing them flipped triggers a TypeError on the first slot mismatch. + $caught = null; + try { + new $pairFqn( + new \App\MultiType\Models\Plastic('red'), + new \App\MultiType\Models\User('alice'), + ); + } catch (\TypeError $e) { + $caught = $e; + } + Assert::assertInstanceOf(\TypeError::class, $caught); +}; diff --git a/test/fixture/compile/multiline_generic_markers/verify/runtime.php b/test/fixture/compile/multiline_generic_markers/verify/runtime.php index d6ccb815..5da8872d 100644 --- a/test/fixture/compile/multiline_generic_markers/verify/runtime.php +++ b/test/fixture/compile/multiline_generic_markers/verify/runtime.php @@ -8,15 +8,18 @@ * declarations; every one of them must have specialized (none emitted * raw), and the emitted program must execute. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame('w', $w->v); -Assert::assertSame(7, $b->v); -Assert::assertSame(41, $n); -Assert::assertSame('s', $s); -Assert::assertSame(2, $c); + Assert::assertSame('w', $w->v); + Assert::assertSame(7, $b->v); + Assert::assertSame(41, $n); + Assert::assertSame('s', $s); + Assert::assertSame(2, $c); +}; diff --git a/test/fixture/compile/nested_typehint/verify/nested_specialization_runtime.php b/test/fixture/compile/nested_typehint/verify/nested_specialization_runtime.php index 112daa5f..e3064719 100644 --- a/test/fixture/compile/nested_typehint/verify/nested_specialization_runtime.php +++ b/test/fixture/compile/nested_typehint/verify/nested_specialization_runtime.php @@ -8,34 +8,37 @@ * type, and calling `setBoxed('not a plastic')` raises a TypeError on * the substituted parameter signature. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoload + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload * registered for `App\NestedTypehint\` + the generated namespace. */ use PHPUnit\Framework\Assert; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; -$plastic = new TypeRef('App\\NestedTypehint\\Models\\Plastic'); -$boxFqn = Registry::generatedFqn('App\\NestedTypehint\\Containers\\Box', [$plastic]); -$wrapperFqn = Registry::generatedFqn('App\\NestedTypehint\\Containers\\Wrapper', [$plastic]); +return function (CompiledFixture $fixture): void { + $plastic = new TypeRef('App\\NestedTypehint\\Models\\Plastic'); + $boxFqn = Registry::generatedFqn('App\\NestedTypehint\\Containers\\Box', [$plastic]); + $wrapperFqn = Registry::generatedFqn('App\\NestedTypehint\\Containers\\Wrapper', [$plastic]); -// Reflection: Wrapper's $box property is typed against the specialized Box. -$propType = (new \ReflectionProperty($wrapperFqn, 'box'))->getType(); -Assert::assertInstanceOf(\ReflectionNamedType::class, $propType); -Assert::assertSame($boxFqn, $propType->getName()); + // Reflection: Wrapper's $box property is typed against the specialized Box. + $propType = (new \ReflectionProperty($wrapperFqn, 'box'))->getType(); + Assert::assertInstanceOf(\ReflectionNamedType::class, $propType); + Assert::assertSame($boxFqn, $propType->getName()); -// Constructor must succeed independently -- otherwise the next -// catch block would falsely attribute its TypeError to setBoxed. -$w = new $wrapperFqn(); -Assert::assertInstanceOf($wrapperFqn, $w); + // Constructor must succeed independently -- otherwise the next + // catch block would falsely attribute its TypeError to setBoxed. + $w = new $wrapperFqn(); + Assert::assertInstanceOf($wrapperFqn, $w); -// TypeError on substituted parameter: setBoxed expects the concrete Plastic, -// not an arbitrary string. -$caught = null; -try { - $w->setBoxed('not a plastic'); -} catch (\TypeError $e) { - $caught = $e; -} -Assert::assertInstanceOf(\TypeError::class, $caught); + // TypeError on substituted parameter: setBoxed expects the concrete Plastic, + // not an arbitrary string. + $caught = null; + try { + $w->setBoxed('not a plastic'); + } catch (\TypeError $e) { + $caught = $e; + } + Assert::assertInstanceOf(\TypeError::class, $caught); +}; diff --git a/test/fixture/compile/qualified_bare_new_defaults/verify/runtime.php b/test/fixture/compile/qualified_bare_new_defaults/verify/runtime.php index fc2b9624..4f70cffc 100644 --- a/test/fixture/compile/qualified_bare_new_defaults/verify/runtime.php +++ b/test/fixture/compile/qualified_bare_new_defaults/verify/runtime.php @@ -9,24 +9,27 @@ * template even with a colliding `use` alias in scope, while the aliased * bare form keeps targeting the aliased template. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/OtherBox.php'; -require $fixture->targetDir . '/Use.php'; -require $fixture->targetDir . '/RelativeUse.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/OtherBox.php'; + require $fixture->targetDir . '/Use.php'; + require $fixture->targetDir . '/RelativeUse.php'; -Assert::assertSame('hi', $a->v); -Assert::assertSame('ho', $b->v); -Assert::assertSame(7, $c->n); + Assert::assertSame('hi', $a->v); + Assert::assertSame('ho', $b->v); + Assert::assertSame(7, $c->n); -// $a and $b are the same App-side specialization; $c is Other's. -Assert::assertSame(get_class($a), get_class($b)); -Assert::assertNotSame(get_class($a), get_class($c)); -// $c is a relocated specialization: emitted under Other's Generated namespace. -Assert::assertSame( - 'XPHP\\Generated\\Other\\Box\\T_6da88c34ba124c41f977db66a4fc5c1a951708d285c81bb0d47c3206f4c27ca8', - get_class($c), -); + // $a and $b are the same App-side specialization; $c is Other's. + Assert::assertSame(get_class($a), get_class($b)); + Assert::assertNotSame(get_class($a), get_class($c)); + // $c is a relocated specialization: emitted under Other's Generated namespace. + Assert::assertSame( + 'XPHP\\Generated\\Other\\Box\\T_6da88c34ba124c41f977db66a4fc5c1a951708d285c81bb0d47c3206f4c27ca8', + get_class($c), + ); +}; diff --git a/test/fixture/compile/qualified_generic_call_sites/verify/runtime.php b/test/fixture/compile/qualified_generic_call_sites/verify/runtime.php index a6176ae8..80275699 100644 --- a/test/fixture/compile/qualified_generic_call_sites/verify/runtime.php +++ b/test/fixture/compile/qualified_generic_call_sites/verify/runtime.php @@ -8,22 +8,25 @@ * `new` against the marker interface, no doubled-namespace template), * route to the SAME specializations as the bare forms, and execute. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(1, $a->v); -Assert::assertSame('r', $b->v); -Assert::assertSame('k', $c->key); -Assert::assertSame(5, $d->v); -Assert::assertSame(9, $e->v); -Assert::assertSame(3, $f); -Assert::assertSame('g', $g); + Assert::assertSame(1, $a->v); + Assert::assertSame('r', $b->v); + Assert::assertSame('k', $c->key); + Assert::assertSame(5, $d->v); + Assert::assertSame(9, $e->v); + Assert::assertSame(3, $f); + Assert::assertSame('g', $g); -// FQ, in-template, and same-line-relative int instantiations must all be -// the one int specialization. -Assert::assertSame(get_class($a), get_class($d)); -Assert::assertSame(get_class($a), get_class($e)); + // FQ, in-template, and same-line-relative int instantiations must all be + // the one int specialization. + Assert::assertSame(get_class($a), get_class($d)); + Assert::assertSame(get_class($a), get_class($e)); +}; diff --git a/test/fixture/compile/relative_names_in_templates/verify/runtime.php b/test/fixture/compile/relative_names_in_templates/verify/runtime.php index 3e86a503..f8f9ebb5 100644 --- a/test/fixture/compile/relative_names_in_templates/verify/runtime.php +++ b/test/fixture/compile/relative_names_in_templates/verify/runtime.php @@ -8,19 +8,22 @@ * `use` alias, never a type parameter), across extends clauses, method * signatures, generic-method receivers, and conformance hierarchies. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/LocalDefs.php'; -require $fixture->targetDir . '/OtherDefs.php'; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/LocalDefs.php'; + require $fixture->targetDir . '/OtherDefs.php'; + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(4, $g->v); -Assert::assertSame('App\\RelativeTemplates\\Base', get_parent_class($g)); -Assert::assertSame(1, $k->generic); -Assert::assertSame('ok', $lbl); -Assert::assertSame(5, $r); -Assert::assertInstanceOf('App\\RelativeTemplates\\Cat', $cat); -Assert::assertSame('App\\RelativeTemplates\\Base', get_parent_class($cat)); + Assert::assertSame(4, $g->v); + Assert::assertSame('App\\RelativeTemplates\\Base', get_parent_class($g)); + Assert::assertSame(1, $k->generic); + Assert::assertSame('ok', $lbl); + Assert::assertSame(5, $r); + Assert::assertInstanceOf('App\\RelativeTemplates\\Cat', $cat); + Assert::assertSame('App\\RelativeTemplates\\Base', get_parent_class($cat)); +}; diff --git a/test/fixture/compile/same_line_marker_pairs/verify/runtime.php b/test/fixture/compile/same_line_marker_pairs/verify/runtime.php index 46c765b0..a6685c6a 100644 --- a/test/fixture/compile/same_line_marker_pairs/verify/runtime.php +++ b/test/fixture/compile/same_line_marker_pairs/verify/runtime.php @@ -7,23 +7,26 @@ * same-spelling pair must have bound its markers to the right sites and * the whole program must execute. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; -require $fixture->targetDir . '/Aliased.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; + require $fixture->targetDir . '/Aliased.php'; -Assert::assertSame(9, $m); -Assert::assertSame(7, $a); -Assert::assertSame(8, $b); -Assert::assertSame(1, $k->v); -Assert::assertSame(2, $p1->v); -Assert::assertSame('s', $p2->v); -Assert::assertNotSame(get_class($p1), get_class($p2)); -Assert::assertSame(11, $t); -Assert::assertSame('App\\SameLinePairs\\B', get_class($bb)); -Assert::assertSame(3, $sum); -Assert::assertSame('v=9', $msg); -Assert::assertSame('al', $hv); + Assert::assertSame(9, $m); + Assert::assertSame(7, $a); + Assert::assertSame(8, $b); + Assert::assertSame(1, $k->v); + Assert::assertSame(2, $p1->v); + Assert::assertSame('s', $p2->v); + Assert::assertNotSame(get_class($p1), get_class($p2)); + Assert::assertSame(11, $t); + Assert::assertSame('App\\SameLinePairs\\B', get_class($bb)); + Assert::assertSame(3, $sum); + Assert::assertSame('v=9', $msg); + Assert::assertSame('al', $hv); +}; diff --git a/test/fixture/compile/scalar_alias_class_resolves/verify/runtime.php b/test/fixture/compile/scalar_alias_class_resolves/verify/runtime.php index 35ea947e..de5b9a1e 100644 --- a/test/fixture/compile/scalar_alias_class_resolves/verify/runtime.php +++ b/test/fixture/compile/scalar_alias_class_resolves/verify/runtime.php @@ -9,14 +9,17 @@ * construction. That all three construct, run, and return their class-typed values proves the alias names * resolve to the classes in argument position. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; -require $fixture->targetDir . '/Use.php'; -Assert::assertInstanceOf(\App\Double::class, $dv, 'Box:: must carry an App\\Double, not a scalar'); -Assert::assertSame(2.5, $dv->f); -Assert::assertInstanceOf(\App\Integer::class, $iv, 'Box:: must carry an App\\Integer'); -Assert::assertSame(7, $iv->i); -Assert::assertInstanceOf(\App\Boolean::class, $bv, 'Box:: must carry an App\\Boolean'); -Assert::assertTrue($bv->b); -echo "OK\n"; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; + Assert::assertInstanceOf(\App\Double::class, $dv, 'Box:: must carry an App\\Double, not a scalar'); + Assert::assertSame(2.5, $dv->f); + Assert::assertInstanceOf(\App\Integer::class, $iv, 'Box:: must carry an App\\Integer'); + Assert::assertSame(7, $iv->i); + Assert::assertInstanceOf(\App\Boolean::class, $bv, 'Box:: must carry an App\\Boolean'); + Assert::assertTrue($bv->b); +}; diff --git a/test/fixture/compile/split_declaration_headers/verify/runtime.php b/test/fixture/compile/split_declaration_headers/verify/runtime.php index f3cd528b..2131efcb 100644 --- a/test/fixture/compile/split_declaration_headers/verify/runtime.php +++ b/test/fixture/compile/split_declaration_headers/verify/runtime.php @@ -7,16 +7,19 @@ * and functions whose headers split across lines (attribute or modifier on * its own line, keyword/name split) must all specialize and run. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame('bx', $b->v); -Assert::assertSame(4, $p->u); -Assert::assertSame(5, $l); -Assert::assertSame('pk', $s); -Assert::assertSame(9, $i); -Assert::assertSame(['z', 'z'], $d); + Assert::assertSame('bx', $b->v); + Assert::assertSame(4, $p->u); + Assert::assertSame(5, $l); + Assert::assertSame('pk', $s); + Assert::assertSame(9, $i); + Assert::assertSame(['z', 'z'], $d); +}; diff --git a/test/fixture/compile/turbofish_fcc_closure/verify/runtime.php b/test/fixture/compile/turbofish_fcc_closure/verify/runtime.php index 96217b19..b8ddac82 100644 --- a/test/fixture/compile/turbofish_fcc_closure/verify/runtime.php +++ b/test/fixture/compile/turbofish_fcc_closure/verify/runtime.php @@ -7,17 +7,20 @@ * specialization emits a valid forwarding closure (the file parses, or this require would * fatal) that routes through the dispatcher and preserves callable semantics. * - * Driver contract: `$fixture` (CompiledFixture) in scope. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame(50, $viaCall); -Assert::assertSame([10, 20, 30], $viaMap); -Assert::assertSame(42, $named); -Assert::assertSame(7, $defaulted); -Assert::assertSame(4, $direct); -Assert::assertSame(8, $viaFcc); -Assert::assertSame(9, $collided); + Assert::assertSame(50, $viaCall); + Assert::assertSame([10, 20, 30], $viaMap); + Assert::assertSame(42, $named); + Assert::assertSame(7, $defaulted); + Assert::assertSame(4, $direct); + Assert::assertSame(8, $viaFcc); + Assert::assertSame(9, $collided); +}; diff --git a/test/fixture/compile/use_trait_generic/verify/runtime.php b/test/fixture/compile/use_trait_generic/verify/runtime.php index 3185ce26..f83d9d78 100644 --- a/test/fixture/compile/use_trait_generic/verify/runtime.php +++ b/test/fixture/compile/use_trait_generic/verify/runtime.php @@ -6,11 +6,14 @@ * Runtime verify for `use_trait_generic`: a generic trait-use survives the WI-08 * use-import reject, specializes, and runs -- the inlined trait method returns. * - * Driver contract: `$fixture` (CompiledFixture) in scope, autoloader registered. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoloader registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; -Assert::assertSame('held', $label); + Assert::assertSame('held', $label); +}; 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 index a0c65175..2d20c423 100644 --- a/test/fixture/compile/variance_edge_preserves_source_parent/verify/runtime.php +++ b/test/fixture/compile/variance_edge_preserves_source_parent/verify/runtime.php @@ -14,12 +14,15 @@ * 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. + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`, autoload registered. */ use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; -require $fixture->targetDir . '/Use.php'; +return function (CompiledFixture $fixture): void { + 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'); + 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 3a6c71e6276a986c926116f08d7256723e5a5711 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sat, 25 Jul 2026 23:44:06 +0000 Subject: [PATCH 02/46] feat(specializer): substitute method-generic turbofish args on all call-node kinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The specialization substitution visitor grounded ATTR_METHOD_GENERIC_ARGS type refs only on FuncCall nodes (the variable-turbofish shape), so a static, instance, or nullsafe method turbofish inside a generic template body (`Maker::wrap::`, `$this->m::`) kept an abstract type-param ref after the enclosing class specialized. Widen the arm to StaticCall, MethodCall, and NullsafeMethodCall. Behavior-inert on its own: nothing consumes the grounded marker on those node kinds yet, so the emit leak guard still rejects the shape — this is the enabling primitive for dispatching it. Co-Authored-By: Claude Fable 5 --- src/Transpiler/Monomorphize/Specializer.php | 29 +++-- .../SpecializerMethodGenericArgsTest.php | 105 ++++++++++++++++-- 2 files changed, 119 insertions(+), 15 deletions(-) diff --git a/src/Transpiler/Monomorphize/Specializer.php b/src/Transpiler/Monomorphize/Specializer.php index bc2c28d6..f4301bcd 100644 --- a/src/Transpiler/Monomorphize/Specializer.php +++ b/src/Transpiler/Monomorphize/Specializer.php @@ -9,6 +9,7 @@ use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\NullsafeMethodCall; +use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Expr\Variable; use PhpParser\Node\Identifier; use PhpParser\Node\Name; @@ -331,15 +332,27 @@ public function __construct(private Substitution $substitution) public function leaveNode(Node $node): ?Node { - // Ground a variable-turbofish call's type arguments in place. An inner + // Ground a method-generic turbofish call's type arguments in place. An inner // `$inner::(...)` inside a generic template body parses as a FuncCall on - // a Variable whose type args live in ATTR_METHOD_GENERIC_ARGS; when the - // enclosing generic specializes (`S → int`) those args must ground too, so - // the per-specialization closure-grounding pass sees `$inner::` and can - // dispatch it. Left un-substituted the closure keeps a raw `I` hint and - // fatals at runtime. (This substitution is e2e-inert until that grounding - // pass runs — it only rewrites the recorded type args, never emits.) - if ($node instanceof FuncCall) { + // a Variable whose type args live in ATTR_METHOD_GENERIC_ARGS; a static / + // instance / nullsafe method turbofish (`Maker::wrap::`, `$this->m::`) + // carries the same attribute on its call node. When the enclosing generic + // specializes (`T → int`) those recorded args must ground too, so a + // downstream grounding pass sees `::` and can dispatch it. Left + // un-substituted the call keeps a raw `T` ref and is rejected by the emit + // leak guard. (This substitution is e2e-inert until a grounding pass + // consumes it — it only rewrites the recorded type args, never emits.) + // @infection-ignore-all LogicalOrAllSubExprNegation — node classes are + // mutually exclusive, so the all-negated disjunction is a tautology + // (every node passes); the arm is still gated on the marker attribute, + // which only call nodes carry, so the mutant is observationally + // equivalent. The per-kind instanceof checks are pinned positively by + // SpecializerMethodGenericArgsTest's call-kind provider. + if ($node instanceof FuncCall + || $node instanceof StaticCall + || $node instanceof MethodCall + || $node instanceof NullsafeMethodCall + ) { $methodArgs = $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); if (is_array($methodArgs) && $methodArgs !== []) { /** @var list $methodArgs — ATTR_METHOD_GENERIC_ARGS is a TypeRef list (set by XphpSourceParser). */ diff --git a/test/Transpiler/Monomorphize/SpecializerMethodGenericArgsTest.php b/test/Transpiler/Monomorphize/SpecializerMethodGenericArgsTest.php index 3ce5e69c..81c86d17 100644 --- a/test/Transpiler/Monomorphize/SpecializerMethodGenericArgsTest.php +++ b/test/Transpiler/Monomorphize/SpecializerMethodGenericArgsTest.php @@ -5,24 +5,35 @@ namespace XPHP\Transpiler\Monomorphize; use PhpParser\Node\Arg; +use PhpParser\Node\Expr; use PhpParser\Node\Expr\FuncCall; +use PhpParser\Node\Expr\MethodCall; +use PhpParser\Node\Expr\NullsafeMethodCall; +use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Expr\Variable; +use PhpParser\Node\Identifier; +use PhpParser\Node\Name; use PhpParser\Node\Param; +use PhpParser\Node\Stmt\ClassMethod; use PhpParser\Node\Stmt\Function_; use PhpParser\Node\Stmt\Return_; use PhpParser\NodeFinder; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; /** - * When a generic template specializes, an inner variable-turbofish call - * (`$inner::(...)`) inside its body carries the enclosing type parameter in - * ATTR_METHOD_GENERIC_ARGS. {@see Specializer::specializeFunction} must ground those - * recorded type arguments (`S` → `int`) so the per-specialization closure-grounding pass - * sees `$inner::` rather than the abstract `$inner::` — otherwise the inner - * closure keeps a raw hint and fatals at runtime. + * When a generic template specializes, a method-generic turbofish call inside its body + * carries the enclosing type parameter in ATTR_METHOD_GENERIC_ARGS — a variable turbofish + * (`$inner::(...)`, a FuncCall), a static call (`Maker::wrap::`), an instance call + * (`$this->m::`), or a nullsafe call (`$obj?->m::`). The Specializer's shared + * substituting visitor must ground the recorded type arguments (`T` → `int`) on every one + * of those call-node kinds so a downstream grounding pass sees `::` rather than the + * abstract `::` — a call kind the substitution skips keeps a raw type-param ref and is + * rejected by the emit leak guard. * * This substitution is end-to-end inert on its own (nothing consumes the grounded args - * until the re-entry pass lands); the assertion is on the recorded attribute directly. + * until a grounding pass dispatches them); the assertion is on the recorded attribute + * directly. */ final class SpecializerMethodGenericArgsTest extends TestCase { @@ -79,4 +90,84 @@ public function testConcreteInnerTurbofishArgsSurviveSpecializationUnchanged(): self::assertIsArray($grounded); self::assertSame('int', $grounded[0]->name); } + + /** + * @return iterable}> + */ + public static function methodTurbofishCallKinds(): iterable + { + $args = [new Arg(new Variable('v'))]; + yield 'static call (Maker::wrap::)' => [ + new StaticCall(new Name('Maker'), new Identifier('wrap'), $args), + StaticCall::class, + ]; + yield 'instance call ($this->m::)' => [ + new MethodCall(new Variable('this'), new Identifier('m'), $args), + MethodCall::class, + ]; + yield 'nullsafe call ($obj?->m::)' => [ + new NullsafeMethodCall(new Variable('obj'), new Identifier('m'), $args), + NullsafeMethodCall::class, + ]; + } + + /** + * @param class-string $nodeClass + */ + #[DataProvider('methodTurbofishCallKinds')] + public function testMethodTurbofishArgsAreGroundedOnEveryCallNodeKind(Expr $call, string $nodeClass): void + { + $call->setAttribute( + XphpSourceParser::ATTR_METHOD_GENERIC_ARGS, + [new TypeRef('T', [], isScalar: false, isTypeParam: true)], + ); + $template = new ClassMethod('make', [ + 'params' => [new Param(new Variable('v'))], + 'stmts' => [new Return_($call)], + ]); + + $specialized = (new Specializer())->specializeMethod( + $template, + Substitution::of(['T' => new TypeRef('int', [], isScalar: true)]), + 'make_T_int', + ); + + $calls = (new NodeFinder())->findInstanceOf($specialized, $nodeClass); + self::assertCount(1, $calls); + $grounded = $calls[0]->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); + self::assertIsArray($grounded); + self::assertCount(1, $grounded); + self::assertInstanceOf(TypeRef::class, $grounded[0]); + self::assertSame('int', $grounded[0]->name); + self::assertFalse($grounded[0]->isTypeParam, 'the grounded arg is concrete, not a type parameter'); + } + + #[DataProvider('methodTurbofishCallKinds')] + public function testNestedTurbofishArgsGroundOnEveryCallNodeKind(Expr $call, string $nodeClass): void + { + // A nested-generic turbofish arg (`Maker::wrap::>`) grounds its leaves in + // place: the outer ref stays `Box`, the inner `T` leaf becomes concrete. + $call->setAttribute( + XphpSourceParser::ATTR_METHOD_GENERIC_ARGS, + [new TypeRef('Box', [new TypeRef('T', [], isScalar: false, isTypeParam: true)], isScalar: false)], + ); + $template = new ClassMethod('make', [ + 'params' => [new Param(new Variable('v'))], + 'stmts' => [new Return_($call)], + ]); + + $specialized = (new Specializer())->specializeMethod( + $template, + Substitution::of(['T' => new TypeRef('string', [], isScalar: true)]), + 'make_T_string', + ); + + $grounded = (new NodeFinder())->findInstanceOf($specialized, $nodeClass)[0] + ->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); + self::assertIsArray($grounded); + self::assertSame('Box', $grounded[0]->name); + self::assertSame('string', $grounded[0]->args[0]->name); + self::assertFalse($grounded[0]->args[0]->isTypeParam); + self::assertTrue($grounded[0]->isConcrete(), 'the whole nested ref is concrete after grounding'); + } } From 26a415186b9553faba05f6df7ff666663306b52e Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 26 Jul 2026 00:12:53 +0000 Subject: [PATCH 03/46] feat(monomorphize): ground named-forward turbofish via an append drain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A generic function forwarding to a named generic (`identity::($v)` inside `wrap`) was rejected with xphp.unspecialized_generic_leak: specialization substituted the inner marker to a concrete `identity::`, but the append flush attached the specialized body without re-walking it, so nothing dispatched the now-groundable call and the leak backstop tripped. Replace the flat flush with a worklist drain: each buffered specialized function/method is attached (compile mode), then re-traversed with the same rewrite visitor in a markers-only mode that touches ONLY named-call turbofish markers — plain-call sweeps, closure-dispatcher tracking, and static/instance markers (whose resolution is not drain-safe; they keep falling to the leak guard) are skipped. Freshly minted appends re-enter the queue: multi-hop chains ground to the bottom, same-args cycles terminate through the alreadyGenerated dedup, and a strictly-growing chain (`grow::>`) is cut off at a 16-hop cap with the new xphp.unconverged_method_specialization error. Appends now carry their declaring-class/namespace context so a detached body resolves against its own scope, not the last-walked file. The drain also runs in check mode (validate-only, nothing attached): bound violations only provable after substitution, non-convergence, and surviving call markers are collected as diagnostics — closing the gap where `xphp check` silently passed shapes `compile` rejects. Sites the source seam already reported are not double-reported (same-position dedupe, and the guard scan's closure-template arm is excluded in check mode via the new GenericMarkerLeakGuard::findLeak/leakMessage API). The generic_function_named_forward fixture (formerly a reject pin) now compiles and runs end to end; new fixtures pin the chain/cycle accepts, the growing rejects (namespaced + bare top-level), the grounded-bound reject, and check/compile parity for each. Co-Authored-By: Claude Fable 5 --- .../Monomorphize/GenericMarkerLeakGuard.php | 54 +++- .../Monomorphize/GenericMethodCompiler.php | 299 ++++++++++++++++-- .../Monomorphize/CheckPassIntegrationTest.php | 58 ++++ .../GenericFunctionIntegrationTest.php | 114 +++++++ .../GenericMarkerLeakGuardTest.php | 41 +++ .../GenericMarkerLeakIntegrationTest.php | 25 +- .../check/forward_growth/source/Use.xphp | 23 ++ .../check/forward_growth_pair/source/Use.xphp | 28 ++ .../source/Use.xphp | 17 + .../check/forward_named_bound/source/Use.xphp | 26 ++ .../check/forward_named_clean/source/Use.xphp | 23 ++ .../source/Use.xphp | 26 ++ .../source/Use.xphp | 40 +++ .../verify/runtime.php | 38 +++ .../source/Use.xphp | 21 ++ .../source/Use.xphp | 23 ++ .../source/Use.xphp | 23 ++ .../verify/runtime.php | 39 +++ .../Use.expected.php | 33 ++ .../source/Use.xphp | 22 -- 20 files changed, 895 insertions(+), 78 deletions(-) create mode 100644 test/fixture/check/forward_growth/source/Use.xphp create mode 100644 test/fixture/check/forward_growth_pair/source/Use.xphp create mode 100644 test/fixture/check/forward_inner_closure_leak/source/Use.xphp create mode 100644 test/fixture/check/forward_named_bound/source/Use.xphp create mode 100644 test/fixture/check/forward_named_clean/source/Use.xphp create mode 100644 test/fixture/compile/generic_function_forward_bound_reject/source/Use.xphp create mode 100644 test/fixture/compile/generic_function_forward_chain/source/Use.xphp create mode 100644 test/fixture/compile/generic_function_forward_chain/verify/runtime.php create mode 100644 test/fixture/compile/generic_function_forward_growth_bare_reject/source/Use.xphp create mode 100644 test/fixture/compile/generic_function_forward_growth_reject/source/Use.xphp create mode 100644 test/fixture/compile/generic_function_named_forward/source/Use.xphp create mode 100644 test/fixture/compile/generic_function_named_forward/verify/runtime.php create mode 100644 test/fixture/compile/generic_function_named_forward/verify/testNamedForwardGroundedByEnclosingParamSpecializes/Use.expected.php delete mode 100644 test/fixture/compile/generic_function_named_forward_leak_reject/source/Use.xphp diff --git a/src/Transpiler/Monomorphize/GenericMarkerLeakGuard.php b/src/Transpiler/Monomorphize/GenericMarkerLeakGuard.php index 9d259833..70edda3d 100644 --- a/src/Transpiler/Monomorphize/GenericMarkerLeakGuard.php +++ b/src/Transpiler/Monomorphize/GenericMarkerLeakGuard.php @@ -45,17 +45,26 @@ final class GenericMarkerLeakGuard public const CODE = 'xphp.unspecialized_generic_leak'; /** - * Throw if any generic marker survives into a specialized AST subtree. + * Find the first surviving generic marker in a specialized AST subtree, or null when + * the subtree is clean. The scan is the guard's single source of truth — `assertNoLeak` + * throws on it, and `check`-mode callers degrade it to a collected diagnostic so the + * validate-only pass reports the same shapes the compile-time backstop rejects. + * + * `$includeClosureTemplates` toggles the defense-in-depth arm. The compile-time + * assert keeps it on. The check-mode drain turns it off: an un-specialized closure + * template inside a drained body always accompanies either a source-seam diagnostic + * on its call site (a different line — the template node's own line would dodge the + * caller's already-reported dedupe) or an orphan diagnostic from the + * declared-but-never-specialized check, so re-flagging the template node itself only + * double-reports; the call-site marker arm is what carries new information there. * * @param Node|list $specialized the emitted specialized node(s) - * @param string $label the specialization's identity, for the error message */ - public static function assertNoLeak(Node|array $specialized, string $label): void + public static function findLeak(Node|array $specialized, bool $includeClosureTemplates = true): ?Node { $nodes = is_array($specialized) ? $specialized : [$specialized]; - $finder = new NodeFinder(); - $leak = $finder->findFirst($nodes, static function (Node $n): bool { + return (new NodeFinder())->findFirst($nodes, static function (Node $n) use ($includeClosureTemplates): bool { if ($n instanceof FuncCall || $n instanceof MethodCall || $n instanceof StaticCall @@ -63,21 +72,25 @@ public static function assertNoLeak(Node|array $specialized, string $label): voi ) { return $n->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS) !== null; } - if ($n instanceof Closure || $n instanceof ArrowFunction) { + if ($includeClosureTemplates && ($n instanceof Closure || $n instanceof ArrowFunction)) { return is_array($n->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS)); } return false; }); + } - if ($leak === null) { - return; - } - + /** + * Build the guard's diagnostic message for a found leak. Shared verbatim between the + * compile-time throw and the check-mode collected diagnostic so both modes name the + * same site the same way. + */ + public static function leakMessage(Node $leak, string $label): string + { // @infection-ignore-all Concat ConcatOperandRemoval — the diagnostic wording is not // behavior: the tests pin that a leak throws and that the message names the label, the // line, and the code; reordering or dropping a prose clause changes none of those. - throw new RuntimeException(sprintf( + return sprintf( 'A generic turbofish/closure marker survived specialization into the emitted output for %s ' . '(near line %d). This site could not be grounded to a concrete type, so its type-parameter ' . 'hints would reach the emitted PHP as references to non-existent classes — a runtime TypeError. ' @@ -87,6 +100,23 @@ public static function assertNoLeak(Node|array $specialized, string $label): voi $label, $leak->getStartLine(), self::CODE, - )); + ); + } + + /** + * Throw if any generic marker survives into a specialized AST subtree. + * + * @param Node|list $specialized the emitted specialized node(s) + * @param string $label the specialization's identity, for the error message + */ + public static function assertNoLeak(Node|array $specialized, string $label): void + { + $leak = self::findLeak($specialized); + + if ($leak === null) { + return; + } + + throw new RuntimeException(self::leakMessage($leak, $label)); } } diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 1cbb6adf..481ffb03 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -94,6 +94,18 @@ final class GenericMethodCompiler public const CODE_UNDETERMINED_RECEIVER = 'xphp.undetermined_receiver'; public const CODE_UNSPECIALIZABLE_SELF_CALL = 'xphp.unspecializable_self_call'; public const CODE_UNSPECIALIZED_GENERIC_CLOSURE = 'xphp.unspecialized_generic_closure'; + public const CODE_UNCONVERGED_METHOD_SPECIALIZATION = 'xphp.unconverged_method_specialization'; + + /** + * Cap on the append-drain's specialization chain depth. A freshly specialized + * function/method body may itself carry a now-concrete turbofish that mints a further + * specialization (`wrap` forwarding to `mid::` forwarding to `identity::`); + * same-args cycles terminate via the alreadyGenerated dedup, but a strictly-growing + * chain (`grow` calling `grow::>`) mints a new mangled name every hop and + * would never converge. Sixteen mirrors Compiler::MAX_SPECIALIZATION_DEPTH (kept as a + * separate constant — this pass must stay independent of the class-level pipeline). + */ + private const MAX_METHOD_SPECIALIZATION_HOPS = 16; /** * @param ?DiagnosticCollector $diagnostics When null (the default — `xphp compile`), every @@ -410,9 +422,30 @@ private function rewriteCallSites( */ private NamespaceContext $nsContext; - /** @var list */ + /** + * Buffered specialized-member appends, flushed (and drained for freshly + * grounded markers) after the traversal. Slot 2 is the drain context: the + * declaring class FQN (methods; null for functions) and the namespace the + * specialized body resolves against — a drained stmt is traversed DETACHED, + * so the visitor's namespace/class state must be primed per item rather + * than inherited from whatever file the main walk last visited. + * + * @var list + */ public array $pendingAppends = []; + /** + * Markers-only mode for drain re-traversals of freshly specialized bodies. + * When true the rewrite pass touches ONLY named-call turbofish markers that + * substitution has made concrete; everything else — plain-call closure-arg + * sweeps, closure-dispatcher tracking (finalize has already run), orphan + * re-checks, static/instance marker rewrites (their name resolution is not + * drain-safe yet; a kept marker falls to the leak guard exactly as before) — + * is skipped so a drained body can neither duplicate diagnostics already + * reported against the template nor mis-ground through stale file state. + */ + public bool $markersOnly = false; + /** Receiver-type analysis state. Pushed on entering ClassLike, popped on leave. */ private ?string $currentClassFqn = null; /** @@ -582,6 +615,34 @@ public function __construct( $this->nsContext = new NamespaceContext(); } + /** + * Reset the visitor's lexical state for one drained (detached) specialized + * stmt. The stmt is traversed outside any Namespace_/Use_/ClassLike parent, + * so enterNode never primes this state — left stale it would resolve names + * against whatever file the main traversal last walked. The use-alias map is + * cleared rather than reconstructed: names the drain needs are attribute- + * resolved (ATTR_TEMPLATE_FQN / ATTR_RESOLVED_FQN at parse time), so aliases + * are never consulted on the markers-only path. + */ + public function primeDrainScope(?string $classFqn, string $namespace): void + { + $this->currentClassFqn = $classFqn; + $this->currentNamespace = $namespace; + $this->currentNamespaceNode = null; + $this->useMap = []; + $this->nsContext = new NamespaceContext(); + $this->nsContext->enterNamespace($namespace !== '' ? $namespace : null); + $this->currentScopeParamTypes = []; + $this->currentScopeLocalTypes = []; + $this->currentScopeParamTypeArgs = []; + $this->currentScopeLocalTypeArgs = []; + $this->branchSnapshots = []; + $this->scopeSnapshots = []; + $this->currentScopeClosureTemplates = []; + $this->currentScopeClosureContexts = []; + $this->callReturnCache = []; + } + public function enterNode(Node $node): null { if ($node instanceof Namespace_) { @@ -856,12 +917,34 @@ public function enterNode(Node $node): null public function leaveNode(Node $node): ?Node { if ($node instanceof StaticCall) { + // Drain traversals leave static markers untouched: their class-name + // resolution is not drain-safe yet, and a kept marker falls to the + // leak guard exactly as it did before the drain existed. + if ($this->markersOnly) { + return null; + } return $this->rewriteStaticCall($node); } if ($node instanceof FuncCall) { + // Drain traversals rewrite ONLY named-call turbofish markers: dispatch + // is ATTR_TEMPLATE_FQN-driven (no lexical resolution), so a detached + // body grounds safely. Bare calls and variable turbofish (`$f::<...>`) + // are skipped — dispatcher finalize has already run, and a surviving + // variable marker stays for the leak guard's closure arm. + if ($this->markersOnly + && (!$node->name instanceof Name + || $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS) === null) + ) { + return null; + } return $this->rewriteFuncCall($node); } if ($node instanceof MethodCall || $node instanceof NullsafeMethodCall) { + // Same as StaticCall: instance-marker grounding inside drained bodies + // is not supported here; the marker survives for the leak guard. + if ($this->markersOnly) { + return null; + } return $this->rewriteInstanceMethodCall($node); } if ($node instanceof ClassLike) { @@ -1285,7 +1368,10 @@ private function rewriteStaticCall(StaticCall $node): ?Node $owner = $this->index->classLike($declaringFqn); if ($owner !== null) { // Buffer the append (see rewriteFuncCall for the rationale). - $this->pendingAppends[] = [$owner, $specialized]; + $this->pendingAppends[] = [$owner, $specialized, [ + 'classFqn' => $declaringFqn, + 'namespace' => self::namespaceOf($declaringFqn), + ]]; $this->alreadyGenerated[$generatedKey] = true; } } @@ -1480,7 +1566,10 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): $specialized = (new Specializer())->specializeMethod($template, Substitution::of($overlay), $mangled); $owner = $this->index->classLike($declaringFqn); if ($owner !== null) { - $this->pendingAppends[] = [$owner, $specialized]; + $this->pendingAppends[] = [$owner, $specialized, [ + 'classFqn' => $declaringFqn, + 'namespace' => self::namespaceOf($declaringFqn), + ]]; $this->alreadyGenerated[$generatedKey] = true; } } @@ -2368,7 +2457,10 @@ private function rewriteFuncCall(FuncCall $node): ?Node // Buffer the append — modifying $namespaceNode->stmts mid-traversal // doesn't reliably propagate through nikic's NodeTraverser. The // outer process() loop flushes pendingAppends after the walk. - $this->pendingAppends[] = [$namespaceNode, $specialized]; + $this->pendingAppends[] = [$namespaceNode, $specialized, [ + 'classFqn' => null, + 'namespace' => $namespace, + ]]; } else { // Bare top-level template (no enclosing `namespace { }` block): // there's no container to append to, so route the specialized @@ -2737,6 +2829,13 @@ private static function lastSegment(string $name): string $pos = strrpos($name, '\\'); return $pos === false ? $name : substr($name, $pos + 1); } + + /** The namespace part of an FQN ('' for a global-namespace symbol). */ + private static function namespaceOf(string $fqn): string + { + $pos = strrpos($fqn, '\\'); + return $pos === false ? '' : substr($fqn, 0, $pos); + } }; $traverser = new NodeTraverser(); @@ -2747,33 +2846,180 @@ private static function lastSegment(string $name): string // diagnostics too (this is a validation, not an emission side-effect). $this->rejectUnspecializedClosureTemplates($ast, $visitor->attemptedClosureTemplates, $currentFile); - // 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 - // site to inject the tag arg. - $this->finalizeClosureDispatchers($visitor, $hashLength); - - // Apply buffered appends now that the traversal has finished, so we don't fight - // nikic's NodeTraverser's child-array iteration semantics mid-walk. Each appended - // node is a fully specialized function/method: guard it against a surviving generic - // marker (a site that could not be grounded) before it reaches emitted output — the - // function-shaped counterpart to the specialized-class backstop in Compiler's emit - // loop. Compile-only: the `!$emit` gate above already returned for `check`. - foreach ($visitor->pendingAppends as [$container, $stmt]) { - $container->stmts[] = $stmt; - GenericMarkerLeakGuard::assertNoLeak($stmt, $currentFile . ' (' . $stmt->name->toString() . ')'); + // site to inject the tag arg. Compile-only: it mutates shared Assign + // nodes and call sites, which check's discarded walk must not do. + if ($emit) { + $this->finalizeClosureDispatchers($visitor, $hashLength); } - foreach ($topLevelAppends as $stmt) { - GenericMarkerLeakGuard::assertNoLeak($stmt, $currentFile . ' (' . $stmt->name->toString() . ')'); + + // Drain the buffered appends now that the traversal has finished, so we don't + // fight nikic's NodeTraverser's child-array iteration semantics mid-walk. Runs + // in BOTH modes: compile attaches, grounds, and backstops each appended body; + // check re-traverses the (discarded) bodies validate-only so diagnostics that + // only become provable after substitution are collected — keeping check and + // compile verdicts aligned. + $this->drainSpecializedAppends($visitor, $currentFile, $emit); + } + + /** + * Flush the buffered specialized appends as a grounding worklist. + * + * Each buffered stmt is a freshly specialized function/method whose body may itself + * carry method-generic turbofish markers that substitution has made concrete + * (`identity::` inside `wrap` becomes `identity::` inside the buffered + * `wrap_T_`). A flat flush would emit those bodies ungrounded — the leak-guard + * backstop tripped on exactly that shape. Instead each stmt is attached (compile + * mode), then re-traversed with the same rewrite visitor in markers-only mode so a + * named-forward marker dispatches and may buffer further appends; the loop repeats + * until both queues drain. Same-args cycles (`a` forwarding to `b` forwarding + * back) terminate through the shared alreadyGenerated dedup — the second visit finds + * the key set and only rewrites the call. A strictly-growing chain mints a fresh + * mangled name every hop and is cut off at MAX_METHOD_SPECIALIZATION_HOPS with a + * loud non-convergence error instead of an endless compile. + * + * Check mode traverses without attaching (the walked ASTs are discarded) and + * degrades both the non-convergence error and the leak backstop to collected + * diagnostics, so `xphp check` reports the shapes `compile` rejects. + */ + private function drainSpecializedAppends(object $visitor, string $currentFile, bool $emit): void + { + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $visitor->markersOnly = true; + // @infection-ignore-all UnwrapFinally — the reset is defensive hygiene: this drain is + // the visitor's last use (one visitor per rewriteCallSites call), so a leftover + // markersOnly=true is dead state today; the finally guards future reuse, not behavior. + try { + $pendingIdx = 0; + $topLevelIdx = 0; + /** @var array $hopDepth spl_object_id(stmt) => chain depth; absent = 1 (buffered by the user-code walk) */ + $hopDepth = []; + while (true) { + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + if ($pendingIdx < count($visitor->pendingAppends)) { + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + [$container, $stmt, $context] = $visitor->pendingAppends[$pendingIdx++]; + if ($emit) { + $container->stmts[] = $stmt; + } + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + } elseif ($topLevelIdx < count($visitor->topLevelAppends)) { + // Top-level (null-namespace) functions have no container node here; + // process() flushes them into the top-level AST array after this + // method returns. They are still grounded + leak-checked like any + // other append. + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $stmt = $visitor->topLevelAppends[$topLevelIdx++]; + $context = ['classFqn' => null, 'namespace' => '']; + } else { + break; + } + + // @infection-ignore-all IncrementInteger DecrementInteger — shifting the + // initial depth by one only offsets where the cap lands (16±1 hops); the + // behavior — a growing chain halts loudly with the unconverged code — is + // pinned by the growth fixtures, and the exact allowance is not contract. + $depth = $hopDepth[spl_object_id($stmt)] ?? 1; + if ($depth > self::MAX_METHOD_SPECIALIZATION_HOPS) { + $message = sprintf( + 'Generic method/function specialization did not converge: grounding "%s" ' + . '(in %s) is %d specialization hops deep — each hop mints a new type argument ' + . '(e.g. a generic forwarding to itself with a nested `Box`), so the chain ' + . 'would never terminate. Break the growth by forwarding a concrete turbofish. [%s]', + $stmt->name->toString(), + $currentFile, + $depth, + self::CODE_UNCONVERGED_METHOD_SPECIALIZATION, + ); + if (!$emit && $this->diagnostics !== null) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + self::CODE_UNCONVERGED_METHOD_SPECIALIZATION, + $message, + new SourceLocation($currentFile, $stmt->getStartLine()), + )); + continue; + } + throw new RuntimeException($message); + } + + // @phpstan-ignore-next-line method.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $visitor->primeDrainScope($context['classFqn'], $context['namespace']); + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $beforePending = count($visitor->pendingAppends); + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $beforeTopLevel = count($visitor->topLevelAppends); + + $traverser = new NodeTraverser(); + assert($visitor instanceof NodeVisitorAbstract); + $traverser->addVisitor($visitor); + $traverser->traverse([$stmt]); + + // @infection-ignore-all IncrementInteger Plus — a coarser per-hop increment + // only halves/offsets the cap allowance; growth still halts loudly with the + // unconverged code (pinned by the growth fixtures) at the same reported depth. + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + for ($i = $beforePending; $i < count($visitor->pendingAppends); $i++) { + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $hopDepth[spl_object_id($visitor->pendingAppends[$i][1])] = $depth + 1; + } + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + for ($i = $beforeTopLevel; $i < count($visitor->topLevelAppends); $i++) { + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $hopDepth[spl_object_id($visitor->topLevelAppends[$i])] = $depth + 1; + } + + $label = $currentFile . ' (' . $stmt->name->toString() . ')'; + if ($emit) { + GenericMarkerLeakGuard::assertNoLeak($stmt, $label); + } elseif ($this->diagnostics !== null) { + // Call-marker arm only: an un-specialized closure template in a + // drained body is always reported elsewhere (seam or orphan check). + $leak = GenericMarkerLeakGuard::findLeak($stmt, includeClosureTemplates: false); + // Suppress the backstop when the site already carries a diagnostic: + // the cloned body preserves the template's line numbers, so a shape + // the source seam rejected with a precise error (e.g. a non-concrete + // variable turbofish, CODE_UNSPECIALIZED_GENERIC_CLOSURE) would + // otherwise double-report here under the vaguer leak code. + if ($leak !== null && !$this->alreadyReportedAt($currentFile, $leak->getStartLine())) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + GenericMarkerLeakGuard::CODE, + GenericMarkerLeakGuard::leakMessage($leak, $label), + new SourceLocation($currentFile, $leak->getStartLine()), + )); + } + } + } + } finally { + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + $visitor->markersOnly = false; } } + /** + * Whether the collector already holds a diagnostic at this exact source position. + * Used by the drain's check-mode backstop to avoid re-reporting a site the source + * seam rejected with a more precise code. + */ + private function alreadyReportedAt(string $file, int $line): bool + { + if ($this->diagnostics === null) { + return false; + } + foreach ($this->diagnostics->all() as $diagnostic) { + if ($diagnostic->location !== null + && $diagnostic->location->file === $file + && $diagnostic->location->line === $line + ) { + return true; + } + } + return false; + } + /** * Reject every generic closure/arrow template no call site attempted to * specialize. Specialization is call-site-driven: with no in-scope @@ -2857,7 +3103,10 @@ private function finalizeClosureDispatchers(object $visitor, int $hashLength): v foreach ($result['declarations'] as $specialized) { if ($entry['namespaceNode'] !== null) { // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. - $visitor->pendingAppends[] = [$entry['namespaceNode'], $specialized]; + $visitor->pendingAppends[] = [$entry['namespaceNode'], $specialized, [ + 'classFqn' => null, + 'namespace' => $entry['namespace'], + ]]; } else { // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. $visitor->topLevelAppends[] = $specialized; diff --git a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php index cd46e6a5..3ce0316b 100644 --- a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php +++ b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php @@ -43,6 +43,64 @@ public function testCleanSourcesProduceNoDiagnostics(): void self::assertSame([], $diagnostics->all()); } + public function testNamedForwardGroundedByEnclosingParamIsCleanInCheck(): void + { + // `wrap` forwarding `identity::` is grounded per specialization by the + // append-drain; the validate-only walk must agree with compile and report + // nothing — no duplicate diagnostics from the drain's re-traversal either. + $diagnostics = $this->check('forward_named_clean'); + + self::assertFalse($diagnostics->hasErrors()); + self::assertSame([], $diagnostics->all()); + } + + public function testGroundedForwardBoundViolationIsCollectedByCheck(): void + { + // `need` forwarded `T = int`: provable only after `wrap::` + // substitutes, so it surfaces from the drain's validate-only traversal — + // matching the compile-side throw (check/compile parity). + $diagnostics = $this->check('forward_named_bound'); + + self::assertCount(1, $diagnostics->all()); + self::assertSame(Registry::CODE_BOUND_VIOLATION, $diagnostics->all()[0]->code); + } + + public function testUnconvergedForwardChainIsCollectedByCheck(): void + { + // `grow` forwarding `grow::>` never converges; check collects the + // drain's hop-cap diagnostic instead of hanging or throwing. + $diagnostics = $this->check('forward_growth'); + + self::assertTrue($diagnostics->hasErrors()); + $codes = array_map(static fn ($d) => $d->code, $diagnostics->all()); + self::assertContains(GenericMethodCompiler::CODE_UNCONVERGED_METHOD_SPECIALIZATION, $codes); + } + + public function testEachUnconvergedChainGetsItsOwnDiagnosticInCheck(): void + { + // Two independent growing chains: hitting the cap on the first stops that + // chain only — the drain keeps going and the second chain reports too. + $diagnostics = $this->check('forward_growth_pair'); + + $unconverged = array_values(array_filter( + $diagnostics->all(), + static fn ($d) => $d->code === GenericMethodCompiler::CODE_UNCONVERGED_METHOD_SPECIALIZATION, + )); + self::assertCount(2, $unconverged); + } + + public function testInnerClosureTurbofishLeakIsCollectedByCheck(): void + { + // A concrete inner closure turbofish (`$f::` inside `outer`) survives + // the drain (variable turbofish stays out of the markers-only pass) and is + // degraded from the compile-time leak throw to a collected diagnostic here. + $diagnostics = $this->check('forward_inner_closure_leak'); + + self::assertTrue($diagnostics->hasErrors()); + $codes = array_map(static fn ($d) => $d->code, $diagnostics->all()); + self::assertContains(GenericMarkerLeakGuard::CODE, $codes); + } + public function testDefaultBoundViolationIsCollectedByCheck(): void { // Exercises the validateDefaultsAgainstBounds() step of check(). diff --git a/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php b/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php index d95b5250..4cc61d4e 100644 --- a/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericFunctionIntegrationTest.php @@ -356,6 +356,120 @@ function bareId(T $x): T } } + public function testNamedForwardGroundedByEnclosingParamSpecializes(): void + { + // `wrap` forwards `identity::($v)` — abstract in the template, concrete + // after `wrap::` / `wrap::` specialize. The append-drain grounds + // each specialized body and dispatches the forward into a real + // `identity_T_` declaration; no marker (and no raw `identity(`) survives. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_function_named_forward/source', + 'genfn-named-forward', + ); + try { + $content = file_get_contents($fixture->targetDir . '/Use.php'); + self::assertIsString($content); + + // Two instantiations → two wrap + two forwarded identity specializations. + self::assertSame(2, preg_match_all('/function wrap_T_[0-9a-f]+\(/', $content)); + self::assertSame(2, preg_match_all('/function identity_T_[0-9a-f]+\(/', $content)); + // Each specialized wrap body calls the matching identity specialization. + self::assertSame(2, preg_match_all('/return \\\\App\\\\NamedForward\\\\identity_T_[0-9a-f]+\(/', $content)); + // Negative invariants: templates stripped, no un-rewritten forward survives + // (`identity::<` only appears in the carried-over source comment's prose). + self::assertStringNotContainsString('function wrap(', $content); + self::assertStringNotContainsString('function identity(', $content); + self::assertStringNotContainsString('return identity::<', $content); + SnapshotHash::assertMatches( + __DIR__ . '/../../fixture/compile/generic_function_named_forward/verify/testNamedForwardGroundedByEnclosingParamSpecializes/Use.expected.php', + $content, + ); + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testNamedForwardRuntimeExecution(): void + { + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_function_named_forward/source', + 'genfn-named-forward-runtime', + ); + try { + $runtime = require __DIR__ . '/../../fixture/compile/generic_function_named_forward/verify/runtime.php'; + $runtime($fixture); + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testForwardChainAndSameArgsCycleRuntimeExecution(): void + { + // 2-hop chain (`wrap` → `mid` → `identity`) and same-args mutual recursion + // (`ping` ↔ `pong`): the drain keeps grounding freshly appended bodies until + // the queue empties, and the specialization dedup terminates the cycle. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_function_forward_chain/source', + 'genfn-forward-chain', + ); + try { + $runtime = require __DIR__ . '/../../fixture/compile/generic_function_forward_chain/verify/runtime.php'; + $runtime($fixture); + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testGrowingForwardChainIsRejectedAsUnconverged(): void + { + // `grow` forwards `grow::>` — every hop mints a deeper type argument, + // so the chain can never converge. The drain's hop cap rejects it loudly. The + // reported depth pins the cap boundary exactly: sixteen allowed hops, failing + // on the seventeenth. + try { + CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_function_forward_growth_reject/source', + 'genfn-forward-growth', + ); + self::fail('expected the growing chain to be rejected'); + } catch (RuntimeException $e) { + self::assertStringContainsString(GenericMethodCompiler::CODE_UNCONVERGED_METHOD_SPECIALIZATION, $e->getMessage()); + self::assertStringContainsString('is 17 specialization hops deep', $e->getMessage()); + } + } + + #[RunInSeparateProcess] + public function testGrowingBareTopLevelForwardChainIsRejectedAsUnconverged(): void + { + // Top-level variant: specializations route through the top-level append bag + // (no Namespace_ container), whose queue the hop cap must bound identically — + // an unbounded bare-file chain would otherwise specialize forever. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(GenericMethodCompiler::CODE_UNCONVERGED_METHOD_SPECIALIZATION); + + CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_function_forward_growth_bare_reject/source', + 'genfn-forward-growth-bare', + ); + } + + #[RunInSeparateProcess] + public function testGroundedForwardBoundViolationFailsCompilation(): void + { + // `need` forwarded `T = int` — the violation is only provable + // after `wrap::` substitutes, so it must fail at the grounding drain. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Generic bound violated while instantiating App\ForwardBound\need'); + + CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_function_forward_bound_reject/source', + 'genfn-forward-bound', + ); + } + private function compile(): void { $compiler = $this->buildCompiler(); diff --git a/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php b/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php index 0440700e..ff6e68e0 100644 --- a/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php +++ b/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php @@ -140,4 +140,45 @@ public function testAcceptsAListOfNodesAndScansEach(): void $this->expectException(RuntimeException::class); GenericMarkerLeakGuard::assertNoLeak([$clean, new Expression($leaking)], 'list'); } + + public function testFindLeakReturnsTheLeakingNodeAndNullOnCleanInput(): void + { + // The check-mode drain consumes the scan directly (degrading to a diagnostic + // instead of a throw), so the found node — not just the boolean outcome — is API. + $leaking = new StaticCall(new Name('self'), new Identifier('gen')); + $leaking->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + + self::assertSame($leaking, GenericMarkerLeakGuard::findLeak(new Expression($leaking))); + self::assertNull(GenericMarkerLeakGuard::findLeak(new Expression(new FuncCall(new Variable('a'))))); + } + + public function testFindLeakCanExcludeTheClosureTemplateArm(): void + { + // With $includeClosureTemplates=false only call-node markers count: an + // un-specialized closure template is reported elsewhere (source seam / orphan + // check), so the check-mode drain must not re-flag the template node itself. + $closure = new Closure(['stmts' => []]); + $closure->setAttribute(self::PARAMS_MARKER, [new Identifier('I')]); + $body = new Expression($closure); + + self::assertSame($closure, GenericMarkerLeakGuard::findLeak($body)); + self::assertNull(GenericMarkerLeakGuard::findLeak($body, includeClosureTemplates: false)); + + // A call-node marker still counts with the closure arm off. + $call = new FuncCall(new Variable('f')); + $call->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + self::assertSame($call, GenericMarkerLeakGuard::findLeak(new Expression($call), includeClosureTemplates: false)); + } + + public function testLeakMessageNamesTheLabelTheLineAndTheCode(): void + { + $call = new FuncCall(new Variable('inner'), [], ['startLine' => 7]); + $call->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + + $message = GenericMarkerLeakGuard::leakMessage($call, 'wrap_T_cafe'); + + self::assertStringContainsString('wrap_T_cafe', $message); + self::assertStringContainsString('7', $message); + self::assertStringContainsString(GenericMarkerLeakGuard::CODE, $message); + } } diff --git a/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php b/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php index 9944ad0c..100421c9 100644 --- a/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php @@ -19,9 +19,12 @@ * `TypeError`/`Error`) behind an otherwise clean compile. The backstop turns each into a loud * compile failure carrying `xphp.unspecialized_generic_leak` before any output is written. * - * (The third shape — a generic closure grounded by an enclosing *function* parameter, `relay` — - * is a non-concrete variable turbofish caught earlier at the source seam in both modes; see - * {@see ClosureDispatcherIntegrationTest} and {@see CheckPassIntegrationTest}.) + * (Two adjacent shapes are handled elsewhere: a generic closure grounded by an enclosing + * *function* parameter, `relay`, is a non-concrete variable turbofish caught earlier at the + * source seam in both modes — see {@see ClosureDispatcherIntegrationTest} and + * {@see CheckPassIntegrationTest}; and a NAMED free-function forward (`identity::` inside + * `wrap`) is grounded and dispatched by the append-drain rather than rejected — see the + * `generic_function_named_forward` runtime fixture in {@see GenericFunctionIntegrationTest}.) * * The must-keep side — a working top-level `$g::` dispatcher and the `contains` * enclosing-bound forward — is proven zero-false-reject by the existing `closure_dispatcher_arrow` @@ -54,20 +57,4 @@ public function testMethodTurbofishGroundedByEnclosingClassParamIsRejected(): vo ); } - #[RunInSeparateProcess] - public function testNamedFreeFunctionForwardGroundedByEnclosingParamIsRejected(): void - { - // A named generic free function forwarded a non-concrete type argument from an - // enclosing function parameter (`identity::($v)` inside `wrap`). The named-call - // turbofish is not a variable turbofish, so it slips past the source seam and reaches - // emit as an appended `wrap_T_` with the marker still present — caught by the - // backstop. Same class of shape as the concrete-inner-turbofish case. - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage(GenericMarkerLeakGuard::CODE); - - CompiledFixture::compile( - __DIR__ . '/../../fixture/compile/generic_function_named_forward_leak_reject/source', - 'generic-function-named-forward-leak', - ); - } } diff --git a/test/fixture/check/forward_growth/source/Use.xphp b/test/fixture/check/forward_growth/source/Use.xphp new file mode 100644 index 00000000..fd3f9608 --- /dev/null +++ b/test/fixture/check/forward_growth/source/Use.xphp @@ -0,0 +1,23 @@ + +{ + public function __construct(public readonly G $value) + { + } +} + +// A strictly-growing forward: every specialization of `grow` mints a deeper type +// argument (`grow` calls `grow::>`, which calls `grow::>>`, +// ...), so the specialization chain can never converge. Rejected loudly by the drain's +// hop cap instead of compiling forever. +function grow(T $v): int +{ + return grow::>(new Box::($v)); +} + +grow::(1); diff --git a/test/fixture/check/forward_growth_pair/source/Use.xphp b/test/fixture/check/forward_growth_pair/source/Use.xphp new file mode 100644 index 00000000..af574159 --- /dev/null +++ b/test/fixture/check/forward_growth_pair/source/Use.xphp @@ -0,0 +1,28 @@ + +{ + public function __construct(public readonly G $value) + { + } +} + +// TWO independent strictly-growing forwards. Check must collect one unconverged +// diagnostic PER chain: hitting the hop cap on the first chain stops that chain only, +// not the whole drain — the second chain still gets its own report. +function growA(T $v): int +{ + return growA::>(new Box::($v)); +} + +function growB(S $v): int +{ + return growB::>(new Box::($v)); +} + +growA::(1); +growB::('x'); diff --git a/test/fixture/check/forward_inner_closure_leak/source/Use.xphp b/test/fixture/check/forward_inner_closure_leak/source/Use.xphp new file mode 100644 index 00000000..a4381920 --- /dev/null +++ b/test/fixture/check/forward_inner_closure_leak/source/Use.xphp @@ -0,0 +1,17 @@ +`) that works at top-level or in a +// plain function, but NOT inside a generic function body: the variable-turbofish +// dispatch does not run for a closure enclosed by a generic function, so the emitted +// `outer_T_` keeps `fn(U $x): U` referencing the non-existent `App\U`. Rejected. +function outer(T $seed): int +{ + $f = fn(U $x): U => $x; + return $f::(41); +} + +outer::('hi'); diff --git a/test/fixture/check/forward_named_bound/source/Use.xphp b/test/fixture/check/forward_named_bound/source/Use.xphp new file mode 100644 index 00000000..0de306da --- /dev/null +++ b/test/fixture/check/forward_named_bound/source/Use.xphp @@ -0,0 +1,26 @@ +` receives `T = int` once `wrap::` specializes. The violation +// is only provable after substitution, so it must fail at the grounding drain — not +// slip through as an unchecked emitted call. +function need(U $x): U +{ + return $x; +} + +function wrap(T $v): T +{ + return need::($v); +} + +wrap::(1); diff --git a/test/fixture/check/forward_named_clean/source/Use.xphp b/test/fixture/check/forward_named_clean/source/Use.xphp new file mode 100644 index 00000000..95ef2b4f --- /dev/null +++ b/test/fixture/check/forward_named_clean/source/Use.xphp @@ -0,0 +1,23 @@ +($v)` is abstract inside the `wrap` +// template, becomes concrete when `wrap` specializes (`wrap::` substitutes +// `identity::`), and the append-drain then grounds and dispatches it into a real +// `identity_T_` specialization. +function identity(U $x): U +{ + return $x; +} + +function wrap(T $v): T +{ + return identity::($v); +} + +$i = wrap::(3); +$s = wrap::('hi'); diff --git a/test/fixture/compile/generic_function_forward_bound_reject/source/Use.xphp b/test/fixture/compile/generic_function_forward_bound_reject/source/Use.xphp new file mode 100644 index 00000000..0de306da --- /dev/null +++ b/test/fixture/compile/generic_function_forward_bound_reject/source/Use.xphp @@ -0,0 +1,26 @@ +` receives `T = int` once `wrap::` specializes. The violation +// is only provable after substitution, so it must fail at the grounding drain — not +// slip through as an unchecked emitted call. +function need(U $x): U +{ + return $x; +} + +function wrap(T $v): T +{ + return need::($v); +} + +wrap::(1); diff --git a/test/fixture/compile/generic_function_forward_chain/source/Use.xphp b/test/fixture/compile/generic_function_forward_chain/source/Use.xphp new file mode 100644 index 00000000..3a1058a4 --- /dev/null +++ b/test/fixture/compile/generic_function_forward_chain/source/Use.xphp @@ -0,0 +1,40 @@ +` → `mid::` → `identity::`), so the append-drain must keep grounding +// until the chain bottoms out. +function identity(U $x): U +{ + return $x; +} + +function mid(V $x): V +{ + return identity::($x); +} + +function wrap(T $v): T +{ + return mid::($v); +} + +// Same-args mutual recursion: ping

forwards to pong::

, whose body forwards back +// to ping::

. Compile-time termination rides the specialization dedup (the second +// visit finds the mangled key already generated and only rewrites the call); runtime +// termination rides the counter. +function ping

(P $v, int $n): P +{ + return $n <= 0 ? $v : pong::

($v, $n - 1); +} + +function pong(Q $v, int $n): Q +{ + return $n <= 0 ? $v : ping::($v, $n - 1); +} + +$a = wrap::(7); +$b = ping::('x', 3); diff --git a/test/fixture/compile/generic_function_forward_chain/verify/runtime.php b/test/fixture/compile/generic_function_forward_chain/verify/runtime.php new file mode 100644 index 00000000..9e0bdbca --- /dev/null +++ b/test/fixture/compile/generic_function_forward_chain/verify/runtime.php @@ -0,0 +1,38 @@ +` → `mid` → `identity`) and the same-args mutual-recursion pair + * (`ping` ↔ `pong`) both execute against the emitted specializations. + */ + +use PHPUnit\Framework\Assert; +use XPHP\Transpiler\Monomorphize\Registry; +use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; + + $intHash = Registry::canonicalHash([new TypeRef('int', isScalar: true)]); + foreach (['wrap', 'mid', 'identity'] as $fn) { + Assert::assertTrue( + function_exists('App\\ForwardChain\\' . $fn . '_T_' . $intHash), + $fn . ' specialization is declared', + ); + } + $wrapInt = 'App\\ForwardChain\\wrap_T_' . $intHash; + Assert::assertSame(7, $wrapInt(7)); + + $stringHash = Registry::canonicalHash([new TypeRef('string', isScalar: true)]); + foreach (['ping', 'pong'] as $fn) { + Assert::assertTrue( + function_exists('App\\ForwardChain\\' . $fn . '_T_' . $stringHash), + $fn . ' specialization is declared', + ); + } + $pingString = 'App\\ForwardChain\\ping_T_' . $stringHash; + Assert::assertSame('x', $pingString('x', 3)); +}; diff --git a/test/fixture/compile/generic_function_forward_growth_bare_reject/source/Use.xphp b/test/fixture/compile/generic_function_forward_growth_bare_reject/source/Use.xphp new file mode 100644 index 00000000..8c44bace --- /dev/null +++ b/test/fixture/compile/generic_function_forward_growth_bare_reject/source/Use.xphp @@ -0,0 +1,21 @@ + +{ + public function __construct(public readonly G $value) + { + } +} + +function grow(T $v): int +{ + return grow::>(new Box::($v)); +} + +grow::(1); diff --git a/test/fixture/compile/generic_function_forward_growth_reject/source/Use.xphp b/test/fixture/compile/generic_function_forward_growth_reject/source/Use.xphp new file mode 100644 index 00000000..fd3f9608 --- /dev/null +++ b/test/fixture/compile/generic_function_forward_growth_reject/source/Use.xphp @@ -0,0 +1,23 @@ + +{ + public function __construct(public readonly G $value) + { + } +} + +// A strictly-growing forward: every specialization of `grow` mints a deeper type +// argument (`grow` calls `grow::>`, which calls `grow::>>`, +// ...), so the specialization chain can never converge. Rejected loudly by the drain's +// hop cap instead of compiling forever. +function grow(T $v): int +{ + return grow::>(new Box::($v)); +} + +grow::(1); diff --git a/test/fixture/compile/generic_function_named_forward/source/Use.xphp b/test/fixture/compile/generic_function_named_forward/source/Use.xphp new file mode 100644 index 00000000..95ef2b4f --- /dev/null +++ b/test/fixture/compile/generic_function_named_forward/source/Use.xphp @@ -0,0 +1,23 @@ +($v)` is abstract inside the `wrap` +// template, becomes concrete when `wrap` specializes (`wrap::` substitutes +// `identity::`), and the append-drain then grounds and dispatches it into a real +// `identity_T_` specialization. +function identity(U $x): U +{ + return $x; +} + +function wrap(T $v): T +{ + return identity::($v); +} + +$i = wrap::(3); +$s = wrap::('hi'); diff --git a/test/fixture/compile/generic_function_named_forward/verify/runtime.php b/test/fixture/compile/generic_function_named_forward/verify/runtime.php new file mode 100644 index 00000000..b4f5737c --- /dev/null +++ b/test/fixture/compile/generic_function_named_forward/verify/runtime.php @@ -0,0 +1,39 @@ +` + * dispatches its forwarded `identity::` call to a real `identity_T_` + * specialization — the emitted chain executes end to end and returns the value + * through both hops. + * + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. + * Free functions aren't autoloadable in PHP, so require the emitted Use.php explicitly + * to bring both specialized declarations into scope (its top-level driver statements + * run too — they exercise the same calls). + */ + +use PHPUnit\Framework\Assert; +use XPHP\Transpiler\Monomorphize\Registry; +use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; + + $intHash = Registry::canonicalHash([new TypeRef('int', isScalar: true)]); + $wrapInt = 'App\\NamedForward\\wrap_T_' . $intHash; + $identityInt = 'App\\NamedForward\\identity_T_' . $intHash; + Assert::assertTrue(function_exists($wrapInt), 'wrap specialization is declared'); + Assert::assertTrue(function_exists($identityInt), 'the forwarded identity specialization is declared'); + Assert::assertSame(3, $wrapInt(3)); + Assert::assertSame(41, $identityInt(41)); + + $stringHash = Registry::canonicalHash([new TypeRef('string', isScalar: true)]); + $wrapString = 'App\\NamedForward\\wrap_T_' . $stringHash; + $identityString = 'App\\NamedForward\\identity_T_' . $stringHash; + Assert::assertTrue(function_exists($wrapString), 'wrap specialization is declared'); + Assert::assertTrue(function_exists($identityString), 'the forwarded identity specialization is declared'); + Assert::assertSame('hi', $wrapString('hi')); +}; diff --git a/test/fixture/compile/generic_function_named_forward/verify/testNamedForwardGroundedByEnclosingParamSpecializes/Use.expected.php b/test/fixture/compile/generic_function_named_forward/verify/testNamedForwardGroundedByEnclosingParamSpecializes/Use.expected.php new file mode 100644 index 00000000..fa817f79 --- /dev/null +++ b/test/fixture/compile/generic_function_named_forward/verify/testNamedForwardGroundedByEnclosingParamSpecializes/Use.expected.php @@ -0,0 +1,33 @@ +($v)` is abstract inside the `wrap` +// template, becomes concrete when `wrap` specializes (`wrap::` substitutes +// `identity::`), and the append-drain then grounds and dispatches it into a real +// `identity_T_` specialization. +function identity_T_6da88c34ba124c41f977db66a4fc5c1a951708d285c81bb0d47c3206f4c27ca8(int $x): int +{ + return $x; +} +// A named generic free function forwarding a type argument grounded by the enclosing +// function's type parameter: `identity::($v)` is abstract inside the `wrap` +// template, becomes concrete when `wrap` specializes (`wrap::` substitutes +// `identity::`), and the append-drain then grounds and dispatches it into a real +// `identity_T_` specialization. +function identity_T_473287f8298dba7163a897908958f7c0eae733e25d2e027992ea2edc9bed2fa8(string $x): string +{ + return $x; +} diff --git a/test/fixture/compile/generic_function_named_forward_leak_reject/source/Use.xphp b/test/fixture/compile/generic_function_named_forward_leak_reject/source/Use.xphp deleted file mode 100644 index c3b41a2b..00000000 --- a/test/fixture/compile/generic_function_named_forward_leak_reject/source/Use.xphp +++ /dev/null @@ -1,22 +0,0 @@ -($v)` depends on `wrap`'s `T`, which is not -// concrete here. The named-call turbofish is not specialized (its args are non-concrete), -// so the emitted `wrap_T_` calls `identity()` with the type parameter still leaked. -// Rejected by the emitted-marker backstop before that fatal-able code is written. -function identity(U $x): U -{ - return $x; -} - -function wrap(T $v): T -{ - return identity::($v); -} - -wrap::(3); From 71e2ea5305b8cc5f678cb0bcdfb5adc3bf7ca0eb Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 26 Jul 2026 00:48:40 +0000 Subject: [PATCH 04/46] feat(monomorphize): ground enclosing-param method turbofish per class specialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A generic class calling a method-generic via turbofish whose type argument is its own type parameter (`self::gen::`, `Maker::wrap::` inside `Box`) was rejected with xphp.unspecialized_generic_leak: the method compiler runs before class specialization, where the enclosing T has no value, and nothing re-visited the marker once specialization made it concrete — while the equivalent constructor turbofish (`new Box::`) grounds through the post-specialization Name rewrite and works. Feed each fresh specialization back through the method compiler from inside the fixed-point loop (groundSpecializedClass): the Phase-1a template index and dedup map are retained past template stripping, the spec's identity (template FQN + concrete args) is threaded so `self::` resolves and the class substitution composes into member specialization, and the walk runs in the append-drain's markers-only mode with parse-time-resolved names. Own-template members land on the specialization itself, deduped per spec and dispatched via `self::` (the template class lowers to a marker interface); non-generic targets append onto the retained user AST, deduped globally across all forwarding classes. Members appended outside the spec are collected explicitly, so an instantiation that first appears inside a grounded body (`new Pair`) converges through the same fixed point. Grounding runs in check's resilient loop too: bound violations only provable after substitution, the unchanged static-context unprovable-bound rejection, and surviving markers are collected as diagnostics located at the template's real source file — closing the gap where `xphp check` silently passed shapes `compile` rejects. Deliberately still rejected (marker kept → emit backstop, now pinned by fixtures): a static target declared on a different generic template, and the `static::` / `parent::` spellings, whose current-class resolution would silently mis-dispatch rather than fail. The generic_class_method_turbofish fixture (formerly a reject pin) now compiles and runs end to end across two instantiations and two forwarding classes, with dedup invariants asserted at runtime. Co-Authored-By: Claude Fable 5 --- src/Transpiler/Monomorphize/Compiler.php | 71 +++- .../Monomorphize/GenericMethodCompiler.php | 331 +++++++++++++++++- .../Monomorphize/GroundingContext.php | 48 +++ .../Monomorphize/CheckPassIntegrationTest.php | 57 +++ .../GenericMarkerLeakIntegrationTest.php | 67 +++- .../GenericMethodIntegrationTest.php | 138 ++++++++ .../method_turbofish_bound/source/Use.xphp | 24 ++ .../method_turbofish_clean/source/Maker.xphp | 14 + .../method_turbofish_clean/source/Use.xphp | 60 ++++ .../source/Use.xphp | 28 ++ .../source/Use.xphp | 24 ++ .../source/Use.xphp | 28 ++ .../source/Maker.xphp | 14 + .../source/Use.xphp | 60 ++++ .../verify/runtime.php | 40 +++ .../BoxInt.expected.php | 34 ++ .../source/Use.xphp | 38 ++ .../verify/runtime.php | 27 ++ .../source/Use.xphp | 25 -- .../source/Use.xphp | 27 ++ .../source/Use.xphp | 25 ++ 21 files changed, 1117 insertions(+), 63 deletions(-) create mode 100644 src/Transpiler/Monomorphize/GroundingContext.php create mode 100644 test/fixture/check/method_turbofish_bound/source/Use.xphp create mode 100644 test/fixture/check/method_turbofish_clean/source/Maker.xphp create mode 100644 test/fixture/check/method_turbofish_clean/source/Use.xphp create mode 100644 test/fixture/check/method_turbofish_cross_template/source/Use.xphp create mode 100644 test/fixture/check/method_turbofish_unprovable/source/Use.xphp create mode 100644 test/fixture/compile/generic_class_cross_template_turbofish_reject/source/Use.xphp create mode 100644 test/fixture/compile/generic_class_method_turbofish/source/Maker.xphp create mode 100644 test/fixture/compile/generic_class_method_turbofish/source/Use.xphp create mode 100644 test/fixture/compile/generic_class_method_turbofish/verify/runtime.php create mode 100644 test/fixture/compile/generic_class_method_turbofish/verify/testMethodTurbofishGroundedByEnclosingClassParamCompiles/BoxInt.expected.php create mode 100644 test/fixture/compile/generic_class_method_turbofish_discovery/source/Use.xphp create mode 100644 test/fixture/compile/generic_class_method_turbofish_discovery/verify/runtime.php delete mode 100644 test/fixture/compile/generic_class_method_turbofish_leak_reject/source/Use.xphp create mode 100644 test/fixture/compile/generic_class_parent_pseudo_turbofish_reject/source/Use.xphp create mode 100644 test/fixture/compile/generic_class_static_pseudo_turbofish_reject/source/Use.xphp diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 3934ede5..1ea37ce7 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -135,8 +135,10 @@ public function compile( // Phase 2: fixed-point specialization loop. Fail-fast (an undefined template // or an exceeded depth throws) — the emit path must not proceed on a set it - // couldn't fully build. - $specializedAsts = $this->specializeToFixedPoint($registry, $collector, $hierarchy, resilient: false); + // couldn't fully build. The method compiler rides along: each fresh + // specialization is grounded (enclosing-param method-generic turbofish + // dispatched against the retained Phase-1a template index) before collection. + $specializedAsts = $this->specializeToFixedPoint($registry, $collector, $hierarchy, resilient: false, methodCompiler: $methodCompiler); // Phase 2.3: re-qualify free-function calls and const fetches in every specialization // produced by the fixed-point loop. Each body was relocated out of its origin namespace @@ -202,12 +204,12 @@ public function compile( $specializedAsts[$generatedFqn] = $first; } - // Note for future-proofing (review F9): method-level specialization runs in Phase 1a - // against the raw user-file ASTs, NOT against the specialized cache classes. That's - // safe under the current MVP limit ("generic methods on non-generic classes only" — - // see GenericMethodCompiler's docblock). If that limit ever relaxes, the specialized - // class ASTs would need to be fed back through the method compiler with their - // enclosing namespace preserved so FQN keying still works. + // Method-level specialization runs twice-shaped: Phase 1a against the raw + // user-file ASTs, then per-specialization inside the Phase-2 loop + // (GenericMethodCompiler::groundSpecializedClass, fed the retained template + // index with the spec's identity threaded — the F9 wiring note this replaces). + // Anything neither pass could ground still carries its marker and is rejected + // by the backstop below. foreach ($specializedAsts as $generatedFqn => $classAst) { // Last-resort safety net: no generic marker may survive into emitted output. A // surviving turbofish/closure marker is a site the pipeline could not ground — @@ -305,6 +307,7 @@ private function specializeToFixedPoint( RegistryCollector $collector, TypeHierarchy $hierarchy, bool $resilient, + ?GenericMethodCompiler $methodCompiler = null, ): array { /** @var array $specializedAsts keyed by generated FQCN */ $specializedAsts = []; @@ -365,6 +368,44 @@ private function specializeToFixedPoint( } $specializedAsts[$generatedFqn] = $specialized; + + // Ground method-generic turbofish markers the class substitution just + // made concrete (`self::gen::` → `::`) BEFORE collecting: an + // own-template member appended onto the spec is then swept by the + // collect below, and externally-appended members (onto a non-generic + // user class or a function namespace — invisible to spec collection) + // are collected explicitly, so nested instantiation needs discovered by + // grounding converge through this same fixed point. + if ($methodCompiler !== null) { + if ($resilient) { + try { + $externalAppends = $methodCompiler->groundSpecializedClass( + $specialized, + $generatedFqn, + $instantiation->templateFqn, + $instantiation->concreteTypes, + emit: false, + ); + } catch (RuntimeException) { + // Grounding failures surface as collected diagnostics in + // check mode; a residual throw must not abort the resilient + // pass over the remaining instantiations. + $externalAppends = []; + } + } else { + $externalAppends = $methodCompiler->groundSpecializedClass( + $specialized, + $generatedFqn, + $instantiation->templateFqn, + $instantiation->concreteTypes, + emit: true, + ); + } + if ($externalAppends !== []) { + $collector->collect($externalAppends, ""); + } + } + $collector->collect([$specialized], ""); } @@ -381,6 +422,10 @@ private function specializeToFixedPoint( } if ($countAfter === $countBefore) { + // @infection-ignore-all Continue_ -- break vs continue reconverges: an + // unchanged count means this pass recorded no new instantiations, so the + // next iteration processes nothing new and exits via the !newlyProcessed + // break; the mutant merely skips that no-op pass. continue; } @@ -481,8 +526,12 @@ public function check(FilepathArray $sources): DiagnosticCollector // 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); + // correct (no-wasted-work, no-mutation) choice. The instance is held: the resilient + // specialization pass below feeds each spec back through it (groundSpecializedClass) so + // enclosing-param turbofish diagnostics only provable after substitution are collected — + // keeping check's verdicts aligned with compile's. + $methodCompiler = new GenericMethodCompiler($this->hashLength, $hierarchy, $diagnostics); + $methodCompiler->process($astPerFile, emit: false); // Grounded closure-signature conformance. A `Closure(T $x)` target whose // type parameter is still abstract above is gradually accepted; grounding it @@ -497,7 +546,7 @@ public function check(FilepathArray $sources): DiagnosticCollector // by-ref) mismatches were already collected by the abstract pre-loop above, so // the grounded pass skips them to avoid a duplicate report at the specialized // location. - $groundedAsts = $this->specializeToFixedPoint($registry, $collector, $hierarchy, resilient: true); + $groundedAsts = $this->specializeToFixedPoint($registry, $collector, $hierarchy, resilient: true, methodCompiler: $methodCompiler); foreach ($groundedAsts as $generatedFqn => $classAst) { $closureValidator->validateFile([$classAst], "", $diagnostics, groundedTypesOnly: true); } diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 481ffb03..f54e3520 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -45,6 +45,7 @@ use PhpParser\Node\Stmt\While_; use PhpParser\Node\UseItem; use PhpParser\NodeTraverser; +use PhpParser\NodeVisitor; use PhpParser\NodeVisitorAbstract; use RuntimeException; use XPHP\Diagnostics\Diagnostic; @@ -56,14 +57,22 @@ * Specializes method-scoped generics: `function NAME(...)` inside a class body, called via * `ClassFqn::NAME(...)`. * - * The pass runs after the class-level pipeline has settled. It walks the per-file AST set - * (the rewritten user code AND the specialized cache classes) twice: + * The pass runs in two stages. `process()` walks the raw per-file user ASTs (Phase 1a, + * before class specialization): * 1. Collect every generic-method template — keyed by "classFqn::methodName". * 2. Collect every StaticCall carrying ATTR_METHOD_GENERIC_ARGS — derive (classFqn, * methodName, args), generate a mangled method (cloning the template, substituting - * the type-param, renaming), append it to the owning class AST. + * the type-param, renaming), append it to the owning class AST — then drain the + * appends, re-walking each freshly specialized body so a forward that substitution + * just made concrete dispatches too. * 3. Strip the original generic-method ClassMethod from each class. * 4. Rewrite each StaticCall's Identifier name to the mangled form. + * Then `groundSpecializedClass()` runs per fresh class specialization inside the + * fixed-point loop, against the retained template index: a turbofish grounded by an + * ENCLOSING class type parameter (`self::gen::` inside `Box`) is abstract during + * Phase 1a and only becomes dispatchable once `Box`'s substitution rewrites the + * marker — own-template members land on the specialization itself, external targets on + * the retained user ASTs. * * Supported call shapes: static (`ClassFqn::method::(...)`), instance and nullsafe * (`$obj->method::(...)`, `$obj?->method::(...)`) via receiver-type analysis, and @@ -113,6 +122,21 @@ final class GenericMethodCompiler * (by `xphp check` with `process(..., emit: false)`), each is appended as a Diagnostic and the * pass continues, so all are reported in one run. */ + /** + * Phase-1a state retained for the post-specialization grounding pass + * ({@see groundSpecializedClass}). `process()` strips generic templates from the + * user ASTs at the end of its run, but the index keeps referencing the detached + * template nodes — retaining it is what lets a marker that only became concrete + * under class specialization still find its method/function template. The dedup + * map is shared too, so a member a Phase-1a call already appended (or another + * specialization already grounded) is never appended twice. + */ + private ?TemplateIndex $retainedIndex = null; + /** @var array shared specialization-dedup keys (see rewriteStaticCall) */ + private array $alreadyGenerated = []; + /** @var array class template FQN => source ast key (filepath), for grounding-time diagnostics */ + private array $classSourceByFqn = []; + public function __construct( private readonly int $hashLength = Registry::DEFAULT_HASH_HEX_LENGTH, private readonly ?TypeHierarchy $hierarchy = null, @@ -185,6 +209,7 @@ public function process(array &$astSet, bool $emit = true): void } foreach ($perFileClasses as $k => $v) { $classByFqn[$k] = $v; + $this->classSourceByFqn[$k] = (string) $astKey; } foreach ($perFileFns as $k => $v) { $functionTemplates[$k] = $v; @@ -226,9 +251,11 @@ public function process(array &$astSet, bool $emit = true): void $functionNamespaceByFqn, $allFunctionsByFqn, ); + // Retain for the post-specialization grounding pass; the template nodes stay + // reachable through the index even after the strip loops below detach them. + $this->retainedIndex = $index; - /** @var array $alreadyGenerated */ - $alreadyGenerated = []; + $alreadyGenerated = &$this->alreadyGenerated; foreach ($astSet as $astKey => &$ast) { // For top-level (null-namespace) functions: the visitor's pendingAppends // mechanism mutates a container's ->stmts; the top-level AST is a plain @@ -292,6 +319,93 @@ public function process(array &$astSet, bool $emit = true): void } } + /** + * Ground + dispatch the method-generic turbofish markers inside one freshly + * specialized class. + * + * Runs from the fixed-point specialization loop, right after the class substitution + * and BEFORE the spec is collected: a marker like `self::gen::` or + * `Maker::wrap::` is abstract at the Phase-1a walk (the enclosing `T` has no + * value in the template) and only becomes dispatchable here, once the substitution + * has rewritten it to `::`. Re-uses the Phase-1a rewrite machinery against the + * retained template index, in the markers-only mode the append-drain introduced, + * plus a {@see GroundingContext} that redirects own-template member appends onto the + * spec itself (the template class lowers to a marker interface in output) and + * records externally-appended members so the caller can collect their nested + * instantiation needs into the same fixed point. + * + * Shapes deliberately NOT grounded here keep their marker and fall to the emit + * backstop exactly as before: instance-call markers, static calls whose declaring + * class is a *different* generic template, `static::`/`parent::` spellings (their + * default resolution would silently mis-ground, not fail), and forwards to a bare + * top-level function template (no container to append to from a detached walk). + * + * In `check` mode (`$emit = false`) nothing is attached; diagnostics only provable + * after substitution (a violated bound, a surviving marker) are collected so check + * and compile agree. + * + * @param list $classArgs the instantiation's concrete type arguments, + * parallel to the template's declared parameters + * @return list members appended onto containers other than the spec + * (user classes / namespaces) — the caller must collect + * these for nested instantiation discovery + */ + public function groundSpecializedClass( + ClassLike $specialized, + string $generatedFqn, + string $templateFqn, + array $classArgs, + bool $emit, + ): array { + $index = $this->retainedIndex; + if ($index === null) { + // process() never built an index (no templates anywhere) — with no method + // or function templates in the program there is nothing a marker could + // dispatch to; any survivor is the emit backstop's to report. + return []; + } + // Cheap pre-scan: most specs carry no marker; skip the visitor entirely then. + if (GenericMarkerLeakGuard::findLeak($specialized, includeClosureTemplates: false) === null) { + return []; + } + + $currentFile = $this->classSourceByFqn[$templateFqn] ?? ""; + $grounding = new GroundingContext($specialized, $generatedFqn, $templateFqn, $classArgs); + /** @var list $topLevelAppends never grows in grounding mode (bare-template forwards keep their marker) */ + $topLevelAppends = []; + $this->rewriteCallSites( + [$specialized], + $index, + $this->alreadyGenerated, + $topLevelAppends, + $currentFile, + $emit, + $grounding, + ); + + // Check-mode parity backstop: compile rejects a still-marked spec at the emit + // loop's assertNoLeak; check has no emit loop, so collect the equivalent + // diagnostic here (call-marker arm only; sites the Phase-1a walk already + // reported — e.g. an unspecializable `$this` self-call — dedupe by position). + if (!$emit && $this->diagnostics !== null) { + // phpstan memoizes the identical pre-scan call above, but the grounding walk + // mutates the AST in between: a marker the pre-scan found is usually + // dispatched (nulled) by now, so this CAN be null. + $leak = GenericMarkerLeakGuard::findLeak($specialized, includeClosureTemplates: false); + // @phpstan-ignore-next-line notIdentical.alwaysTrue + if ($leak !== null && !$this->alreadyReportedAt($currentFile, $leak->getStartLine())) { + $this->diagnostics->add(new Diagnostic( + Severity::Error, + GenericMarkerLeakGuard::CODE, + GenericMarkerLeakGuard::leakMessage($leak, $generatedFqn), + new SourceLocation($currentFile, $leak->getStartLine()), + )); + } + } + + return $grounding->externalAppends; + } + /** * @param list $ast * @param array $methodTemplates out-param @@ -398,6 +512,7 @@ private function rewriteCallSites( array &$topLevelAppends, string $currentFile, bool $emit, + ?GroundingContext $grounding = null, ): void { $hashLength = $this->hashLength; $hierarchy = $this->hierarchy; @@ -446,6 +561,16 @@ private function rewriteCallSites( */ public bool $markersOnly = false; + /** + * Set (together with markersOnly) when this walk grounds a freshly + * specialized CLASS ({@see GenericMethodCompiler::groundSpecializedClass}). + * Widens the markers-only pass to static-call markers — their resolution + * is drain-safe here because names are attribute-resolved and the spec's + * own identity is threaded — and drives the append-target rule: an + * own-template member lands on the spec, deduped per specialization. + */ + public ?GroundingContext $grounding = null; + /** Receiver-type analysis state. Pushed on entering ClassLike, popped on leave. */ private ?string $currentClassFqn = null; /** @@ -643,8 +768,18 @@ public function primeDrainScope(?string $classFqn, string $namespace): void $this->callReturnCache = []; } - public function enterNode(Node $node): null + public function enterNode(Node $node): null|int { + // A markers-only walk skips generic declarations still carrying their + // template marker wholesale: in check mode nothing is stripped, so a + // spec clone contains the generic-method templates themselves, whose + // method-param-leaf markers the Phase-1a walk already validated — + // re-walking them would duplicate diagnostics (or false-flag closure + // templates the template walk already handled). leaveNode mirrors the + // test so it never pops a scope this skip never pushed. + if ($this->markersOnly && self::isUnspecializedTemplateDeclaration($node)) { + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } if ($node instanceof Namespace_) { $this->currentNamespace = $node->name?->toString() ?? ''; $this->currentNamespaceNode = $node; @@ -917,11 +1052,21 @@ public function enterNode(Node $node): null public function leaveNode(Node $node): ?Node { if ($node instanceof StaticCall) { - // Drain traversals leave static markers untouched: their class-name - // resolution is not drain-safe yet, and a kept marker falls to the - // leak guard exactly as it did before the drain existed. if ($this->markersOnly) { - return null; + // Phase-1a drain (no grounding context): static resolution is + // not drain-safe there; a kept marker falls to the leak guard + // exactly as it did before the drain existed. Grounding a + // specialized class DOES process static markers — but only + // marker-bearing ones, and never the `static::`/`parent::` + // spellings: `resolveClassName` maps both to the current class, + // which would silently mis-ground a late-bound or parent-side + // dispatch; keeping the marker fails loudly instead. + if ($this->grounding === null + || $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS) === null + || $this->isLateBoundPseudoName($node->class) + ) { + return null; + } } return $this->rewriteStaticCall($node); } @@ -950,6 +1095,11 @@ public function leaveNode(Node $node): ?Node if ($node instanceof ClassLike) { $this->currentClassFqn = null; } + // Mirror of enterNode's markers-only template skip: the enter never + // pushed a scope for this declaration, so the pop below must not run. + if ($this->markersOnly && self::isUnspecializedTemplateDeclaration($node)) { + return null; + } if ($node instanceof Function_ || $node instanceof ClassMethod || $node instanceof Closure @@ -1263,7 +1413,15 @@ private function rewriteStaticCall(StaticCall $node): ?Node return null; } - $classFqn = $this->resolveClassName($node->class); + // A drained/grounding walk runs over a DETACHED body: lexical use-alias + // state is unavailable, so prefer the parse-time resolution attribute + // (`self` carries none and still routes through resolveClassName, whose + // currentClassFqn was primed per item). The normal Phase-1a walk keeps + // its lexical resolution byte-identical. + $resolvedAttr = $node->class->getAttribute(XphpSourceParser::ATTR_RESOLVED_FQN); + $classFqn = $this->markersOnly && is_string($resolvedAttr) + ? $resolvedAttr + : $this->resolveClassName($node->class); $methodName = $node->name->toString(); $key = $classFqn . '::' . $methodName; // Resolve through the inheritance chain (same as the instance path): @@ -1280,6 +1438,17 @@ private function rewriteStaticCall(StaticCall $node): ?Node return $this->reportUnresolvedTurbofishOrSkip($classFqn, $methodName, $node); } [$template, $declaringFqn] = $resolved; + // Grounding mode: only the spec's OWN template's members may land on the + // spec. A static target declared on a *different* generic template would + // need that other template's substitution mapping (and mutating a shared + // template mid-loop is order-dependent wrong code) — keep the marker and + // let the emit backstop reject it as a documented limitation. + if ($this->grounding !== null + && $declaringFqn !== $this->grounding->templateFqn + && $this->isGenericTemplateClass($declaringFqn) + ) { + return null; + } $params = $template->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); if (!is_array($params)) { return null; @@ -1356,6 +1525,41 @@ private function rewriteStaticCall(StaticCall $node): ?Node } $mangled = self::mangleName($methodName, $args, $this->hashLength); + + // Grounding a spec whose OWN template declares the target: the member + // lands on the spec itself — the template class lowers to a marker + // interface in output, so an append there would vanish (and mutating a + // shared template mid-loop would be order-dependent). Dedup per + // specialization, but consult the global key first: a member a concrete + // Phase-1a call already appended onto the template was cloned INTO this + // spec, and appending again would redeclare the method (load-time fatal). + if ($this->grounding !== null && $declaringFqn === $this->grounding->templateFqn) { + $templateKey = $declaringFqn . '::' . $mangled; + $specKey = $this->grounding->generatedFqn . '::' . $mangled; + if (!isset($this->alreadyGenerated[$templateKey]) && !isset($this->alreadyGenerated[$specKey])) { + // Compose the enclosing class substitution under the method's + // own overlay: the detached template's body may reference class + // type parameters, which are concrete for THIS spec only. + $overlay = $this->groundingClassOverlay(); + foreach ($params as $i => $param) { + $overlay[$param->name] = $args[$i]; + } + $specialized = (new Specializer())->specializeMethod($template, Substitution::of($overlay), $mangled); + $this->pendingAppends[] = [$this->grounding->spec, $specialized, [ + 'classFqn' => $declaringFqn, + 'namespace' => self::namespaceOf($declaringFqn), + ]]; + $this->alreadyGenerated[$specKey] = true; + } + $node->name = new Identifier($mangled, $node->name->getAttributes()); + $node->setAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS, null); + // Dispatch through `self`: the member lives on the emitted spec, and + // the template FQN spelling (`Box::gen` / `\App\Box::gen`) would + // resolve to the stripped marker interface. + $node->class = new Name('self', $node->class->getAttributes()); + return $node; + } + // Emit onto the declaring class (see the instance path) so subclasses // inherit the single specialization; dedup by the declaring FQN. $generatedKey = $declaringFqn . '::' . $mangled; @@ -2447,6 +2651,14 @@ private function rewriteFuncCall(FuncCall $node): ?Node $generatedKey = 'fn::' . $mangledFqn; if (!isset($this->alreadyGenerated[$generatedKey])) { + // Grounding a specialized class: a BARE top-level function template + // has no container node, and the top-level bag routes into whatever + // file the walk was invoked for — which, for a detached spec, is no + // file at all (the append would be dropped silently). Keep the + // marker instead; the emit backstop rejects it loudly. + if ($this->grounding !== null && $this->index->functionNamespaceNode($fqn) === null) { + return null; + } $overlay = []; foreach ($params as $i => $param) { $overlay[$param->name] = $args[$i]; @@ -2836,15 +3048,95 @@ private static function namespaceOf(string $fqn): string $pos = strrpos($fqn, '\\'); return $pos === false ? '' : substr($fqn, 0, $pos); } + + /** + * A function/method/closure declaration still carrying its generic-template + * marker — never present in a compile-mode spec (templates are stripped or + * lowered before cloning), but present in check-mode clones; markers-only + * walks skip them wholesale (see enterNode). + */ + private static function isUnspecializedTemplateDeclaration(Node $node): bool + { + return ($node instanceof ClassMethod + || $node instanceof Function_ + || $node instanceof Closure + || $node instanceof ArrowFunction) + && is_array($node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS)); + } + + /** + * A `static::` / `parent::` class spelling — late-bound (or parent-side) + * dispatch that `resolveClassName`'s currentClassFqn mapping would silently + * mis-ground in a detached grounding walk. + */ + private function isLateBoundPseudoName(Node $class): bool + { + if (!$class instanceof Name) { + return false; + } + $first = strtolower($class->getParts()[0]); + return $first === 'static' || $first === 'parent'; + } + + /** Whether the FQN names a generic class template (declares type parameters). */ + private function isGenericTemplateClass(string $fqn): bool + { + $params = $this->index->classLike($fqn)?->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + return is_array($params) && $params !== []; + } + + /** + * The enclosing template's type-parameter → concrete-argument map for the + * spec being grounded, read off the RETAINED template node (the spec clone's + * generic attributes were nulled by the Specializer). + * + * @return array + */ + private function groundingClassOverlay(): array + { + if ($this->grounding === null) { + return []; + } + $classParams = $this->index->classLike($this->grounding->templateFqn) + ?->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + if (!is_array($classParams)) { + return []; + } + /** @var list $classParams — set as a list by XphpSourceParser::resolveAndAttach. */ + $overlay = []; + foreach ($classParams as $i => $classParam) { + if (isset($this->grounding->classArgs[$i])) { + $overlay[$classParam->name] = $this->grounding->classArgs[$i]; + } + } + return $overlay; + } }; + if ($grounding !== null) { + // Grounding a specialized class: markers-only walk, identity primed from + // the context (the spec clone is nameless and namespace-less — enterNode + // would never see a Namespace_/name to derive them from). + $visitor->markersOnly = true; + $visitor->grounding = $grounding; + $visitor->primeDrainScope( + $grounding->templateFqn, + self::namespacePrefixOf($grounding->templateFqn), + ); + } + $traverser = new NodeTraverser(); $traverser->addVisitor($visitor); $traverser->traverse($ast); // Runs BEFORE the emit gate: check mode must collect the orphan // diagnostics too (this is a validation, not an emission side-effect). - $this->rejectUnspecializedClosureTemplates($ast, $visitor->attemptedClosureTemplates, $currentFile); + // Skipped in grounding mode: a markers-only walk records no attempts, so an + // inner generic closure the template walk already handled would false-flag + // as an orphan on every specialization. + if ($grounding === null) { + $this->rejectUnspecializedClosureTemplates($ast, $visitor->attemptedClosureTemplates, $currentFile); + } // Pass 2 of the closure-dispatcher pipeline: materialize a dispatcher // closure per recorded template, replace the original Assign's RHS, @@ -2904,6 +3196,14 @@ private function drainSpecializedAppends(object $visitor, string $currentFile, b if ($emit) { $container->stmts[] = $stmt; } + // Grounding mode: a member appended onto anything but the spec + // itself (a non-generic user class, a function namespace) is + // invisible to the fixed-point loop's spec collection — record it + // so the caller can collect its nested instantiation needs. + // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. + if ($visitor->grounding !== null && $container !== $visitor->grounding->spec) { + $visitor->grounding->externalAppends[] = $stmt; + } // @phpstan-ignore-next-line property.notFound — $visitor is an anonymous class declared above; phpstan can't name its shape. } elseif ($topLevelIdx < count($visitor->topLevelAppends)) { // Top-level (null-namespace) functions have no container node here; @@ -2999,6 +3299,13 @@ private function drainSpecializedAppends(object $visitor, string $currentFile, b } } + /** The namespace part of an FQN ('' for a global-namespace symbol). */ + private static function namespacePrefixOf(string $fqn): string + { + $pos = strrpos($fqn, '\\'); + return $pos === false ? '' : substr($fqn, 0, $pos); + } + /** * Whether the collector already holds a diagnostic at this exact source position. * Used by the drain's check-mode backstop to avoid re-reporting a site the source diff --git a/src/Transpiler/Monomorphize/GroundingContext.php b/src/Transpiler/Monomorphize/GroundingContext.php new file mode 100644 index 00000000..8b43bd16 --- /dev/null +++ b/src/Transpiler/Monomorphize/GroundingContext.php @@ -0,0 +1,48 @@ +` → `self::gen::`). Grounding them re-uses the Phase-1a + * rewrite machinery, but three decisions differ from a user-file walk and are driven by + * this context: + * + * - **identity**: the walked ClassLike is a nameless clone; `templateFqn` stands in as + * the current class FQN so `self::` resolution and method-template lookup key against + * the retained Phase-1a index, and `classArgs` are the instantiation's concrete type + * arguments (parallel to the template's declared parameters) for composing the class + * substitution into method specialization. + * - **append target**: a member specialized from the spec's own template lands on the + * spec itself (`spec`), deduped per specialization via `generatedFqn` — never on the + * template class, which lowers to a marker interface in emitted output. + * - **external collection**: members appended onto OTHER containers (a non-generic user + * class, a function namespace) are recorded in `externalAppends` so the fixed-point + * loop can collect their nested instantiation needs — user files were collected before + * these members existed. + */ +final class GroundingContext +{ + /** @var list members appended onto containers other than the spec */ + public array $externalAppends = []; + + /** + * @param list $classArgs concrete instantiation args, parallel to the + * template's declared type parameters + */ + public function __construct( + public readonly ClassLike $spec, + public readonly string $generatedFqn, + public readonly string $templateFqn, + public readonly array $classArgs, + ) { + } +} diff --git a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php index 3ce0316b..879649f4 100644 --- a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php +++ b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php @@ -54,6 +54,63 @@ public function testNamedForwardGroundedByEnclosingParamIsCleanInCheck(): void self::assertSame([], $diagnostics->all()); } + public function testMethodTurbofishGroundedByEnclosingClassParamIsCleanInCheck(): void + { + // `self::gen::` / `Maker::wrap::` inside `Box` ground per + // specialization; the validate-only pass must agree with compile and report + // nothing — across two instantiations and two forwarding classes. + $diagnostics = $this->check('method_turbofish_clean'); + + self::assertFalse($diagnostics->hasErrors()); + self::assertSame([], $diagnostics->all()); + } + + public function testGroundedStaticBoundViolationIsCollectedByCheck(): void + { + // `gen` grounded with `T = int`: provable only after + // Box specializes, collected by the per-specialization grounding pass. + // The location must point at the template's real source file (the grounding + // pass resolves it through the retained class-source map), not a synthetic + // `` label. + $diagnostics = $this->check('method_turbofish_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); + } + + public function testCrossTemplateStaticTurbofishLeakIsCollectedByCheck(): void + { + // `Other::gen::` (a static method-generic on a DIFFERENT generic template) + // stays un-grounded by design; compile rejects at the emit backstop, and check + // must collect the same leak diagnostic from the grounding pass — located at + // the template's real source file. + $diagnostics = $this->check('method_turbofish_cross_template'); + + self::assertTrue($diagnostics->hasErrors()); + $leaks = array_values(array_filter( + $diagnostics->all(), + static fn ($d) => $d->code === GenericMarkerLeakGuard::CODE, + )); + self::assertCount(1, $leaks); + self::assertNotNull($leaks[0]->location); + self::assertStringEndsWith('Use.xphp', $leaks[0]->location->file); + } + + public function testClassParamBoundOnStaticIsStillUnprovableInCheck(): void + { + // `gen` on a static method-generic: genuinely unprovable in a static + // context — the pre-existing rejection survives the grounding pass in check + // exactly as in compile. + $diagnostics = $this->check('method_turbofish_unprovable'); + + self::assertTrue($diagnostics->hasErrors()); + $codes = array_map(static fn ($d) => $d->code, $diagnostics->all()); + self::assertContains(GenericMethodCompiler::CODE_BOUND_UNPROVABLE, $codes); + } + public function testGroundedForwardBoundViolationIsCollectedByCheck(): void { // `need` forwarded `T = int`: provable only after `wrap::` diff --git a/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php b/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php index 100421c9..8795e602 100644 --- a/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php @@ -12,19 +12,22 @@ /** * End-to-end coverage for the emitted-generic-marker backstop ({@see GenericMarkerLeakGuard}). * - * Two enclosing-parameter / generic-function-scope turbofish shapes slip past the source-level - * gates and reach the emit phase un-grounded: a *concrete* inner closure turbofish inside a - * generic function body, and a method turbofish grounded by an enclosing class type parameter. - * Left to emit, each produces PHP that references a non-existent type-parameter class (a runtime - * `TypeError`/`Error`) behind an otherwise clean compile. The backstop turns each into a loud - * compile failure carrying `xphp.unspecialized_generic_leak` before any output is written. + * The turbofish shapes that deliberately stay un-groundable reach the emit phase with their + * marker intact: a *concrete* inner closure turbofish inside a generic function body, a static + * method-generic declared on a *different* generic template, and the late-bound + * `static::`/`parent::` spellings. Left to emit, each produces PHP that references a + * non-existent type-parameter class or the wrong dispatch target behind an otherwise clean + * compile. The backstop turns each into a loud compile failure carrying + * `xphp.unspecialized_generic_leak` before any output is written. * - * (Two adjacent shapes are handled elsewhere: a generic closure grounded by an enclosing - * *function* parameter, `relay`, is a non-concrete variable turbofish caught earlier at the - * source seam in both modes — see {@see ClosureDispatcherIntegrationTest} and - * {@see CheckPassIntegrationTest}; and a NAMED free-function forward (`identity::` inside - * `wrap`) is grounded and dispatched by the append-drain rather than rejected — see the - * `generic_function_named_forward` runtime fixture in {@see GenericFunctionIntegrationTest}.) + * (Adjacent shapes are handled elsewhere: a generic closure grounded by an enclosing *function* + * parameter, `relay`, is a non-concrete variable turbofish caught earlier at the source seam in + * both modes — see {@see ClosureDispatcherIntegrationTest} and {@see CheckPassIntegrationTest}; + * a NAMED free-function forward (`identity::` inside `wrap`) is grounded by the + * append-drain — see `generic_function_named_forward` in {@see GenericFunctionIntegrationTest}; + * and an own-template / non-generic-target method turbofish grounded by the enclosing class + * parameter (`self::gen::`, `Maker::wrap::`) is grounded per specialization — see + * `generic_class_method_turbofish` in {@see GenericMethodIntegrationTest}.) * * The must-keep side — a working top-level `$g::` dispatcher and the `contains` * enclosing-bound forward — is proven zero-false-reject by the existing `closure_dispatcher_arrow` @@ -46,15 +49,49 @@ public function testConcreteInnerTurbofishInsideAGenericFunctionIsRejected(): vo } #[RunInSeparateProcess] - public function testMethodTurbofishGroundedByEnclosingClassParamIsRejected(): void + public function testCrossTemplateStaticTurbofishIsRejected(): void { + // A static method-generic declared on a DIFFERENT generic template + // (`Other::gen::` from inside `Holder`): the grounding pass deliberately + // leaves the marker (it would need Other's own substitution mapping, and + // appending onto a shared template mid-loop is order-dependent), so the + // backstop rejects. The own-template and non-generic-target forms of the same + // call shape ground and run — see the generic_class_method_turbofish fixture. $this->expectException(RuntimeException::class); $this->expectExceptionMessage(GenericMarkerLeakGuard::CODE); CompiledFixture::compile( - __DIR__ . '/../../fixture/compile/generic_class_method_turbofish_leak_reject/source', - 'generic-class-method-turbofish-leak', + __DIR__ . '/../../fixture/compile/generic_class_cross_template_turbofish_reject/source', + 'generic-class-cross-template-turbofish', ); } + #[RunInSeparateProcess] + public function testStaticPseudoNameTurbofishIsRejected(): void + { + // `static::gen::`: honoring late static binding is impossible for the + // grounding pass, and resolving `static` to the current class would silently + // re-route a subclass dispatch — keep-marker + loud reject is the contract. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(GenericMarkerLeakGuard::CODE); + + CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_class_static_pseudo_turbofish_reject/source', + 'generic-class-static-pseudo-turbofish', + ); + } + + #[RunInSeparateProcess] + public function testParentPseudoNameTurbofishIsRejected(): void + { + // `parent::gen::`: same contract as `static::` — the current-class mapping + // would dispatch to the wrong side of the hierarchy, so the marker is kept. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(GenericMarkerLeakGuard::CODE); + + CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_class_parent_pseudo_turbofish_reject/source', + 'generic-class-parent-pseudo-turbofish', + ); + } } diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php index 42aff925..88af0b95 100644 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php @@ -1699,6 +1699,144 @@ class Box { public static function get(T $x): T { return $x; } } } } + #[RunInSeparateProcess] + public function testMethodTurbofishGroundedByEnclosingClassParamCompiles(): void + { + // `self::gen::` / `Maker::wrap::` inside `Box`: abstract in the + // template, grounded and dispatched per specialization. The spec carries its + // own `gen_T_` member (dispatched via `self::`, appended once despite + // two call sites) and the non-generic Maker gets one shared `wrap_T_` + // per unique argument tuple. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_class_method_turbofish/source', + 'genmethod-enclosing-turbofish', + ); + try { + $generated = self::globRecursive($fixture->cacheDir . '/Generated', '*.php'); + self::assertCount(3, $generated, 'Box, Box, CoBox'); + + $specs = array_combine($generated, array_map('file_get_contents', $generated)); + $boxIntSpec = null; + foreach ($specs as $path => $content) { + self::assertIsString($content); + if (str_contains($path, '/Box/') && str_contains($content, 'make(int $v): int')) { + $boxIntSpec = $content; + } + } + self::assertIsString($boxIntSpec, 'Box spec found'); + // One appended member, dispatched via self:: (never the marker interface). + self::assertSame(1, preg_match_all('/function gen_T_[0-9a-f]+\(/', $boxIntSpec)); + self::assertSame(2, preg_match_all('/self::gen_T_[0-9a-f]+\(/', $boxIntSpec)); + self::assertStringNotContainsString('Box::gen', $boxIntSpec); + SnapshotHash::assertMatches( + __DIR__ . '/../../fixture/compile/generic_class_method_turbofish/verify/testMethodTurbofishGroundedByEnclosingClassParamCompiles/BoxInt.expected.php', + $boxIntSpec, + ); + + // Maker (a plain user file) carries exactly two wrap specializations — + // int (shared by Box and CoBox) and string. + $maker = file_get_contents($fixture->targetDir . '/Maker.php'); + self::assertIsString($maker); + self::assertSame(2, preg_match_all('/function wrap_T_[0-9a-f]+\(/', $maker)); + + $fixture->registerAutoload('App\\MethodTurbofish'); + $runtime = require __DIR__ . '/../../fixture/compile/generic_class_method_turbofish/verify/runtime.php'; + $runtime($fixture); + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testGroundingAppendedMemberInstantiationsAreCollected(): void + { + // `Pair` first exists inside the `twin_T_` member the grounding + // pass appends onto Maker — its instantiation must be collected into the + // fixed point or the emitted call references a missing generated class. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_class_method_turbofish_discovery/source', + 'genmethod-turbofish-discovery', + ); + try { + $pairSpecs = array_filter( + self::globRecursive($fixture->cacheDir . '/Generated', '*.php'), + static fn (string $p): bool => str_contains($p, '/Pair/'), + ); + self::assertCount(1, $pairSpecs, 'Pair was discovered and specialized'); + + $fixture->registerAutoload('App\\TurbofishDiscovery'); + $runtime = require __DIR__ . '/../../fixture/compile/generic_class_method_turbofish_discovery/verify/runtime.php'; + $runtime($fixture); + } finally { + $fixture->cleanup(); + } + } + + #[RunInSeparateProcess] + public function testGroundedStaticBoundViolationFailsCompilation(): void + { + // `gen` grounded with `T = int` once Box specializes: + // provable only after substitution, must fail at the grounding pass. + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Generic bound violated while instantiating App\Box::gen'); + + CompiledFixture::compile( + __DIR__ . '/../../fixture/check/method_turbofish_bound/source', + 'genmethod-turbofish-bound', + ); + } + + #[RunInSeparateProcess] + public function testClassParamBoundOnStaticStaysUnprovable(): void + { + // `gen` on a STATIC method: a class-param bound has no receiver to + // ground it in a static context — the pre-existing rejection must survive + // the grounding pass unchanged. + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cannot verify generic bound `U : T` for App\Box::gen'); + + CompiledFixture::compile( + __DIR__ . '/../../fixture/check/method_turbofish_unprovable/source', + 'genmethod-turbofish-unprovable', + ); + } + + #[RunInSeparateProcess] + public function testGroundedStaticBoundSatisfiedCompilesAndRuns(): void + { + // The bound twin that must keep compiling: `gen` grounded + // with a T that satisfies the bound. + $dir = sys_get_temp_dir() . '/xphp-genmethod-bound-ok-' . uniqid('', true); + mkdir($dir, 0o755, true); + file_put_contents($dir . '/Use.xphp', <<<'PHP' + s; } + } + class Box { + public function m(T $v): T { return self::gen::($v); } + public static function gen(U $x): U { return $x; } + } + $b = new Box::

`, grounded to Pair): own-spec members are collected + // even though check attaches nothing, so check agrees with compile's reject. + $diagnostics = $this->check('method_turbofish_nested_bound'); + + self::assertTrue($diagnostics->hasErrors()); + $codes = array_map(static fn ($d) => $d->code, $diagnostics->all()); + self::assertContains(Registry::CODE_BOUND_VIOLATION, $codes); + } + + public function testDeferredTurbofishArityErrorIsReportedOncePerSite(): void + { + // An arity error on a deferred enclosing-param turbofish under TWO + // instantiations: one diagnostic at the source site — the per-spec grounding + // walks must not re-fire the same collector message per specialization. + $diagnostics = $this->check('method_turbofish_arity_once'); + + self::assertCount(1, $diagnostics->all()); + self::assertSame(Registry::CODE_TOO_MANY_TYPE_ARGUMENTS, $diagnostics->all()[0]->code); + } + + public function testTemplateTargetOutsideItsOwnSpecLeakIsCollectedByCheck(): void + { + // The compile-side keep-marker contract for a template-owned target named + // outside the template's own body (see the compile reject fixture) holds in + // check too: exactly one leak diagnostic, at the real source site. + $diagnostics = $this->check('template_target_outside_spec'); + + $leaks = array_values(array_filter( + $diagnostics->all(), + static fn ($d) => $d->code === GenericMarkerLeakGuard::CODE, + )); + self::assertCount(1, $leaks); + self::assertNotNull($leaks[0]->location); + self::assertStringEndsWith('Use.xphp', $leaks[0]->location->file); + } + public function testCrossTemplateStaticTurbofishLeakIsCollectedByCheck(): void { // `Other::gen::` (a static method-generic on a DIFFERENT generic template) diff --git a/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php b/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php index ff6e68e0..3a19fa54 100644 --- a/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php +++ b/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php @@ -170,6 +170,34 @@ public function testFindLeakCanExcludeTheClosureTemplateArm(): void self::assertSame($call, GenericMarkerLeakGuard::findLeak(new Expression($call), includeClosureTemplates: false)); } + public function testFindLeakCanExcludeVariableTurbofishCalls(): void + { + // With $includeVariableTurbofish=false a FuncCall on a VARIABLE (`$f::`) + // is not a leak — check's class-spec backstop uses this because dispatchers + // are only materialized in compile mode. Named calls still count. + $varCall = new FuncCall(new Variable('f')); + $varCall->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + $body = new Expression($varCall); + + self::assertSame($varCall, GenericMarkerLeakGuard::findLeak($body)); + self::assertNull(GenericMarkerLeakGuard::findLeak($body, includeVariableTurbofish: false)); + + $namedCall = new FuncCall(new Name('identity')); + $namedCall->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + self::assertSame( + $namedCall, + GenericMarkerLeakGuard::findLeak(new Expression($namedCall), includeVariableTurbofish: false), + ); + + // Static/instance markers are unaffected by the toggle. + $static = new StaticCall(new Name('self'), new Identifier('gen')); + $static->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + self::assertSame( + $static, + GenericMarkerLeakGuard::findLeak(new Expression($static), includeVariableTurbofish: false), + ); + } + public function testLeakMessageNamesTheLabelTheLineAndTheCode(): void { $call = new FuncCall(new Variable('inner'), [], ['startLine' => 7]); diff --git a/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php b/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php index 8795e602..d27bc459 100644 --- a/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericMarkerLeakIntegrationTest.php @@ -66,6 +66,23 @@ public function testCrossTemplateStaticTurbofishIsRejected(): void ); } + #[RunInSeparateProcess] + public function testTemplateTargetOutsideItsOwnSpecIsRejected(): void + { + // `Holder::gen::` written inside Maker's body (a NON-member of Holder): + // grounding Holder drains Maker's freshly appended member, but the + // own-template arm must not fire there — a `self::` rewrite inside Maker + // would call a member Maker doesn't have (a runtime fatal). Keep-marker + + // backstop is the contract. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(GenericMarkerLeakGuard::CODE); + + CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_class_template_target_outside_spec_reject/source', + 'generic-class-target-outside-spec', + ); + } + #[RunInSeparateProcess] public function testStaticPseudoNameTurbofishIsRejected(): void { diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php index dfbd9f82..d9d8a294 100644 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php @@ -1894,6 +1894,25 @@ public function testErasablePlainCallerForwardReusesLoweredMember(): void } } + #[RunInSeparateProcess] + public function testStaticInheritedTurbofishLandsOnTheCallingSpec(): void + { + // `self::gen::` where gen lives on generic `Base`: mirrors the + // instance-call rule — the member grounds through the extends chain onto the + // Holder spec, never the shared Base template. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_class_static_inherited_turbofish/source', + 'genmethod-static-inherited', + ); + try { + $fixture->registerAutoload('App\\StaticInherited'); + $runtime = require __DIR__ . '/../../fixture/compile/generic_class_static_inherited_turbofish/verify/runtime.php'; + $runtime($fixture); + } finally { + $fixture->cleanup(); + } + } + #[RunInSeparateProcess] public function testInstanceCrossTemplateTurbofishIsRejected(): void { diff --git a/test/fixture/check/class_closure_dispatcher_clean/source/Use.xphp b/test/fixture/check/class_closure_dispatcher_clean/source/Use.xphp new file mode 100644 index 00000000..f622c446 --- /dev/null +++ b/test/fixture/check/class_closure_dispatcher_clean/source/Use.xphp @@ -0,0 +1,21 @@ +` marker — the grounding +// pass's backstop must not flag it (that would reject code compile accepts). +final class Box +{ + public function m(T $seed): int + { + $f = fn(U $x): U => $x; + return $f::(41); + } +} + +$b = new Box::(); +$b->m(1); diff --git a/test/fixture/check/method_turbofish_arity_once/source/Use.xphp b/test/fixture/check/method_turbofish_arity_once/source/Use.xphp new file mode 100644 index 00000000..7030b416 --- /dev/null +++ b/test/fixture/check/method_turbofish_arity_once/source/Use.xphp @@ -0,0 +1,24 @@ + +{ + public function m(T $v): mixed + { + return self::gen::($v); + } + + public static function gen(U $x): U + { + return $x; + } +} + +$a = new Box::(); +$b = new Box::(); diff --git a/test/fixture/check/method_turbofish_nested_bound/source/Use.xphp b/test/fixture/check/method_turbofish_nested_bound/source/Use.xphp new file mode 100644 index 00000000..d45eae3a --- /dev/null +++ b/test/fixture/check/method_turbofish_nested_bound/source/Use.xphp @@ -0,0 +1,38 @@ +` instantiates +// `Pair` (bounded by Labeled), which only becomes `Pair` when Holder's +// grounding specializes gen. Check must collect it from the grounded member exactly +// as compile rejects it — own-spec members are collected even though check attaches +// nothing. +final class Pair

+{ + public function __construct(public readonly P $v) + { + } +} + +final class Holder +{ + public function go(T $v): mixed + { + return self::gen::($v); + } + + public static function gen(U $x): mixed + { + return new Pair::($x); + } +} + +$h = new Holder::(); +$h->go(1); diff --git a/test/fixture/check/template_target_outside_spec/source/Use.xphp b/test/fixture/check/template_target_outside_spec/source/Use.xphp new file mode 100644 index 00000000..d1490867 --- /dev/null +++ b/test/fixture/check/template_target_outside_spec/source/Use.xphp @@ -0,0 +1,37 @@ + drains Maker's freshly appended wrap member, whose body names +// Holder::gen::. Dispatching that through the spec's `self::` would emit a call to +// a member Maker doesn't have (a runtime fatal) — the marker is kept and the +// emitted-marker backstop rejects loudly instead. +final class Maker +{ + /** @return array */ + public static function wrap(X $v): array + { + return Holder::gen::($v); + } +} + +final class Holder +{ + /** @return array */ + public static function gen(U $x): array + { + return [$x, $x]; + } + + /** @return array */ + public function go(T $v): array + { + return Maker::wrap::($v); + } +} + +$h = new Holder::(); +$h->go(7); diff --git a/test/fixture/compile/generic_class_static_inherited_turbofish/source/Use.xphp b/test/fixture/compile/generic_class_static_inherited_turbofish/source/Use.xphp new file mode 100644 index 00000000..64747675 --- /dev/null +++ b/test/fixture/compile/generic_class_static_inherited_turbofish/source/Use.xphp @@ -0,0 +1,37 @@ +` inside the +// Holder spec threads Holder's concrete arguments through the extends chain to Base's +// parameters and lands the member on the HOLDER specialization — mirroring the +// instance-call rule, never mutating the shared Base template. +class Base +{ + /** @return array */ + public static function gen(U $x): array + { + return [$x, $x]; + } + + public function idT(T $v): T + { + return $v; + } +} + +class Holder extends Base +{ + /** @return array */ + public function go(T $v): array + { + return self::gen::($v); + } +} + +$b = new Base::(); +$h = new Holder::(); +$r1 = $h->go(3); +$r2 = $b->idT('s'); diff --git a/test/fixture/compile/generic_class_static_inherited_turbofish/verify/runtime.php b/test/fixture/compile/generic_class_static_inherited_turbofish/verify/runtime.php new file mode 100644 index 00000000..bdebf578 --- /dev/null +++ b/test/fixture/compile/generic_class_static_inherited_turbofish/verify/runtime.php @@ -0,0 +1,34 @@ + spec grew nothing. + */ + +use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Use.php'; + + Assert::assertSame([3, 3], $r1); + Assert::assertSame('s', $r2); + + $genMembers = array_values(array_filter( + get_class_methods($h), + static fn (string $m): bool => str_starts_with($m, 'gen_T_'), + )); + Assert::assertCount(1, $genMembers); + Assert::assertSame( + get_class($h), + (new ReflectionMethod($h, $genMembers[0]))->getDeclaringClass()->getName(), + 'member declared on the Holder spec itself', + ); + Assert::assertSame([], array_values(array_filter( + get_class_methods($b), + static fn (string $m): bool => str_starts_with($m, 'gen_T_'), + )), 'the Base spec grew no grounded member'); +}; diff --git a/test/fixture/compile/generic_class_template_target_outside_spec_reject/source/Use.xphp b/test/fixture/compile/generic_class_template_target_outside_spec_reject/source/Use.xphp new file mode 100644 index 00000000..d1490867 --- /dev/null +++ b/test/fixture/compile/generic_class_template_target_outside_spec_reject/source/Use.xphp @@ -0,0 +1,37 @@ + drains Maker's freshly appended wrap member, whose body names +// Holder::gen::. Dispatching that through the spec's `self::` would emit a call to +// a member Maker doesn't have (a runtime fatal) — the marker is kept and the +// emitted-marker backstop rejects loudly instead. +final class Maker +{ + /** @return array */ + public static function wrap(X $v): array + { + return Holder::gen::($v); + } +} + +final class Holder +{ + /** @return array */ + public static function gen(U $x): array + { + return [$x, $x]; + } + + /** @return array */ + public function go(T $v): array + { + return Maker::wrap::($v); + } +} + +$h = new Holder::(); +$h->go(7); From 955646bece109cf92656cba94740c5c82d36a9c8 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 26 Jul 2026 10:11:17 +0000 Subject: [PATCH 08/46] fix(monomorphize): prune retained templates from the check backstop, severity-aware dedupe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-up fixes to the grounding pass's check-mode alignment, each pinned by a fixture that fails without it: The class-spec leak backstop now skips the SUBTREES of declarations still carrying their generic-template marker: check never strips templates, so a spec clone retains e.g. `a` whose body legitimately holds a `self::b::` marker — flagging that interior rejected a 2-hop own-template forward chain that compile grounds and runs. Compile-mode specs never contain such declarations, so the assert path is unchanged. The position dedupe (grounding-skip and leak-backstop sides) matches ERRORS only: a same-line warning previously suppressed grounding entirely, letting a bound violation nested in the grounded member pass check while compile rejects — the dangerous direction. Own-spec appended members now drain under the SPEC's identity rather than their declaring class's: the member lives on the spec, so `$this`/`self` in its body are the spec — an ancestor-declared member whose body forwards again (`self::genB::` declared on Base) now grounds hop by hop onto the calling spec instead of leaking spuriously after the first hop. Co-Authored-By: Claude Fable 5 --- .../Monomorphize/GenericMarkerLeakGuard.php | 62 ++++++++++++++++++- .../Monomorphize/GenericMethodCompiler.php | 37 ++++++++--- .../Monomorphize/CheckPassIntegrationTest.php | 29 +++++++++ .../GenericMarkerLeakGuardTest.php | 60 ++++++++++++++++++ .../GenericMethodIntegrationTest.php | 19 ++++++ .../source/Use.xphp | 34 ++++++++++ .../source/Use.xphp | 48 ++++++++++++++ .../source/Use.xphp | 34 ++++++++++ .../verify/runtime.php | 23 +++++++ .../source/Use.xphp | 9 +++ .../verify/runtime.php | 20 +++--- 11 files changed, 355 insertions(+), 20 deletions(-) create mode 100644 test/fixture/check/method_turbofish_two_hop_clean/source/Use.xphp create mode 100644 test/fixture/check/method_turbofish_warning_same_line/source/Use.xphp create mode 100644 test/fixture/compile/generic_class_method_forward_chain/source/Use.xphp create mode 100644 test/fixture/compile/generic_class_method_forward_chain/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/GenericMarkerLeakGuard.php b/src/Transpiler/Monomorphize/GenericMarkerLeakGuard.php index 5af128b8..f55b0197 100644 --- a/src/Transpiler/Monomorphize/GenericMarkerLeakGuard.php +++ b/src/Transpiler/Monomorphize/GenericMarkerLeakGuard.php @@ -12,7 +12,12 @@ use PhpParser\Node\Expr\NullsafeMethodCall; use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Name; +use PhpParser\Node\Stmt\ClassMethod; +use PhpParser\Node\Stmt\Function_; use PhpParser\NodeFinder; +use PhpParser\NodeTraverser; +use PhpParser\NodeVisitor; +use PhpParser\NodeVisitorAbstract; use RuntimeException; /** @@ -67,16 +72,25 @@ final class GenericMarkerLeakGuard * elsewhere (the source seam in both modes, or the append-drain backstop, whose * check side keeps this arm on because compile's drain rejects the same body). * + * `$skipUnspecializedTemplates` skips the SUBTREES of function/method/closure + * declarations still carrying their generic-template marker. Same check-mode + * class-spec backstop rationale: check never strips templates, so a spec clone + * retains e.g. `a` whose body legitimately holds a `self::b::` marker — the + * template as a whole is dispatch machinery, not emitted output, and flagging its + * interior would reject code compile accepts. Compile-mode specs never contain + * such declarations, so the assert path is unaffected. + * * @param Node|list $specialized the emitted specialized node(s) */ public static function findLeak( Node|array $specialized, bool $includeClosureTemplates = true, bool $includeVariableTurbofish = true, + bool $skipUnspecializedTemplates = false, ): ?Node { $nodes = is_array($specialized) ? $specialized : [$specialized]; - return (new NodeFinder())->findFirst($nodes, static function (Node $n) use ($includeClosureTemplates, $includeVariableTurbofish): bool { + $matcher = static function (Node $n) use ($includeClosureTemplates, $includeVariableTurbofish): bool { if ($n instanceof FuncCall || $n instanceof MethodCall || $n instanceof StaticCall @@ -92,7 +106,51 @@ public static function findLeak( } return false; - }); + }; + + if (!$skipUnspecializedTemplates) { + return (new NodeFinder())->findFirst($nodes, $matcher); + } + + // Subtree-skipping scan: NodeFinder can't prune, so walk with a traverser that + // refuses to descend into declarations still carrying the template marker. + // Preorder like findFirst, so both paths report the same first leak. + $visitor = new class($matcher) extends NodeVisitorAbstract { + public ?Node $leak = null; + + /** @param \Closure(Node): bool $matcher */ + public function __construct(private readonly \Closure $matcher) + { + } + + public function enterNode(Node $node): ?int + { + if ($this->leak !== null) { + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + if (($node instanceof ClassMethod || $node instanceof Function_ + || $node instanceof Closure || $node instanceof ArrowFunction) + && is_array($node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS)) + ) { + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + if (($this->matcher)($node)) { + $this->leak = $node; + // @infection-ignore-all ReturnRemoval — descending into the found + // leak's children is a no-op: the leak-set early-exit above prunes + // every subsequent node before the matcher can overwrite. The + // prune is an optimization; first-leak-wins is pinned by + // GenericMarkerLeakGuardTest's document-order test. + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + return null; + } + }; + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($nodes); + + return $visitor->leak; } /** diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 872af807..e4a83833 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -388,14 +388,17 @@ public function groundSpecializedClass( // diagnostic here (call-marker arm only; sites the Phase-1a walk already // reported — e.g. an unspecializable `$this` self-call — dedupe by position). if (!$emit && $this->diagnostics !== null) { - // Variable-turbofish markers are excluded: check never materializes closure - // dispatchers, so a class-spec clone legitimately carries the `$f::` - // marker compile's dispatcher pass grounds — flagging it would reject code - // compile accepts. + // Variable-turbofish markers and the interiors of retained generic-method + // templates are excluded: check never materializes closure dispatchers nor + // strips templates, so a class-spec clone legitimately carries a `$f::` + // marker (compile's dispatcher pass grounds it) and template bodies with + // method-param markers (compile clones stripped classes) — flagging either + // would reject code compile accepts. $leak = GenericMarkerLeakGuard::findLeak( $specialized, includeClosureTemplates: false, includeVariableTurbofish: false, + skipUnspecializedTemplates: true, ); if ($leak !== null && !$this->alreadyReportedAt($currentFile, $leak->getStartLine())) { $this->diagnostics->add(new Diagnostic( @@ -1577,9 +1580,14 @@ private function rewriteStaticCall(StaticCall $node): ?Node $groundingClassSubst->withOverrides(Substitution::of($overlay)), $mangled, ); + // Drain context = the SPEC's template, not the declaring class: + // the member now lives on the spec, so `$this`/`self` inside its + // drained body are the spec — an ancestor-declared member whose + // body forwards again (`self::genB::` on Base) must pass the + // in-spec site guard, or a groundable chain leaks spuriously. $this->pendingAppends[] = [$this->grounding->spec, $specialized, [ - 'classFqn' => $declaringFqn, - 'namespace' => self::namespaceOf($declaringFqn), + 'classFqn' => $this->grounding->templateFqn, + 'namespace' => self::namespaceOf($this->grounding->templateFqn), ]]; $this->alreadyGenerated[$specKey] = true; } @@ -1830,9 +1838,12 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): $classSubst->withOverrides(Substitution::of($overlay)), $mangled, ); + // Drain context = the SPEC's template (see the static arm): the + // member lives on the spec, so its drained body's `$this`/`self` + // are the spec and further own-chain forwards keep grounding. $this->pendingAppends[] = [$this->grounding->spec, $specialized, [ - 'classFqn' => $declaringFqn, - 'namespace' => self::namespaceOf($declaringFqn), + 'classFqn' => $this->grounding->templateFqn, + 'namespace' => self::namespaceOf($this->grounding->templateFqn), ]]; $this->alreadyGenerated[$specKey] = true; } @@ -3176,7 +3187,11 @@ private function siteAlreadyReported(int $line): bool return false; } foreach ($this->diagnostics->all() as $diagnostic) { - if ($diagnostic->location !== null + // Errors only: a same-line WARNING must not suppress grounding — + // that would mask a real error the grounded walk would surface + // (check-green on code compile rejects, the dangerous direction). + if ($diagnostic->severity === Severity::Error + && $diagnostic->location !== null && $diagnostic->location->file === $this->currentFile && $diagnostic->location->line === $line ) { @@ -3475,7 +3490,9 @@ private function alreadyReportedAt(string $file, int $line): bool return false; } foreach ($this->diagnostics->all() as $diagnostic) { - if ($diagnostic->location !== null + // Errors only — a same-line warning must not swallow the leak backstop. + if ($diagnostic->severity === Severity::Error + && $diagnostic->location !== null && $diagnostic->location->file === $file && $diagnostic->location->line === $line ) { diff --git a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php index a4e57112..58460ada 100644 --- a/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php +++ b/test/Transpiler/Monomorphize/CheckPassIntegrationTest.php @@ -10,6 +10,7 @@ use PHPUnit\Framework\TestCase; use RuntimeException; use XPHP\Diagnostics\DiagnosticCollector; +use XPHP\Diagnostics\Severity; use XPHP\FileSystem\FileFinder\NativeFileFinder; use XPHP\FileSystem\FilepathArray; use XPHP\FileSystem\FileReader\NativeFileReader; @@ -116,6 +117,34 @@ public function testDeferredTurbofishArityErrorIsReportedOncePerSite(): void self::assertSame(Registry::CODE_TOO_MANY_TYPE_ARGUMENTS, $diagnostics->all()[0]->code); } + public function testTwoHopOwnTemplateForwardChainIsCleanInCheck(): void + { + // Compile grounds `go` → `self::a::` → `self::b::` and the program + // runs; check must stay silent — its un-stripped spec clone retains the + // `a`/`b` templates, whose interior method-param markers are dispatch + // machinery, not leaks. + $diagnostics = $this->check('method_turbofish_two_hop_clean'); + + self::assertFalse($diagnostics->hasErrors()); + self::assertSame([], $diagnostics->all()); + } + + public function testSameLineWarningDoesNotMaskAGroundedError(): void + { + // A warning-producing construct shares the source line with the deferred + // turbofish: the position dedupe is severity-aware, so grounding still runs + // and the bound violation nested in the grounded member surfaces — check + // must not go green on code compile rejects. + $diagnostics = $this->check('method_turbofish_warning_same_line'); + + self::assertTrue($diagnostics->hasErrors()); + $errorCodes = array_map( + static fn ($d) => $d->code, + array_filter($diagnostics->all(), static fn ($d) => $d->severity === Severity::Error), + ); + self::assertContains(Registry::CODE_BOUND_VIOLATION, $errorCodes); + } + public function testTemplateTargetOutsideItsOwnSpecLeakIsCollectedByCheck(): void { // The compile-side keep-marker contract for a template-owned target named diff --git a/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php b/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php index 3a19fa54..073ef43d 100644 --- a/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php +++ b/test/Transpiler/Monomorphize/GenericMarkerLeakGuardTest.php @@ -198,6 +198,66 @@ public function testFindLeakCanExcludeVariableTurbofishCalls(): void ); } + public function testFindLeakCanSkipUnspecializedTemplateInteriors(): void + { + // With $skipUnspecializedTemplates=true, the SUBTREE of a declaration still + // carrying its generic-template marker is not scanned: check-mode spec clones + // retain method templates whose interior markers are dispatch machinery, not + // leaks. Each declaration kind prunes independently. + $makeMarkedCall = static function (): StaticCall { + $call = new StaticCall(new Name('self'), new Identifier('gen')); + $call->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + return $call; + }; + + $method = new \PhpParser\Node\Stmt\ClassMethod('a', ['stmts' => [new Return_($makeMarkedCall())]]); + $method->setAttribute(self::PARAMS_MARKER, [new Identifier('U')]); + $function = new \PhpParser\Node\Stmt\Function_('f', ['stmts' => [new Return_($makeMarkedCall())]]); + $function->setAttribute(self::PARAMS_MARKER, [new Identifier('U')]); + $closure = new Closure(['stmts' => [new Return_($makeMarkedCall())]]); + $closure->setAttribute(self::PARAMS_MARKER, [new Identifier('U')]); + $arrow = new ArrowFunction(['expr' => $makeMarkedCall()]); + $arrow->setAttribute(self::PARAMS_MARKER, [new Identifier('U')]); + + foreach ([$method, $function, new Expression($closure), new Expression($arrow)] as $decl) { + self::assertNotNull( + GenericMarkerLeakGuard::findLeak($decl, includeClosureTemplates: false), + 'without the skip, the interior marker is a leak', + ); + self::assertNull( + GenericMarkerLeakGuard::findLeak($decl, includeClosureTemplates: false, skipUnspecializedTemplates: true), + 'with the skip, the template interior is pruned', + ); + } + + // A declaration WITHOUT the template marker is scanned normally... + $plainMethod = new \PhpParser\Node\Stmt\ClassMethod('b', ['stmts' => [new Return_($makeMarkedCall())]]); + self::assertNotNull( + GenericMarkerLeakGuard::findLeak($plainMethod, includeClosureTemplates: false, skipUnspecializedTemplates: true), + ); + // ...and the params attribute on a NON-declaration node never prunes (the skip + // is scoped to the four declaration kinds precisely). + $decoy = new Expression($makeMarkedCall()); + $decoy->setAttribute(self::PARAMS_MARKER, [new Identifier('U')]); + self::assertNotNull( + GenericMarkerLeakGuard::findLeak($decoy, includeClosureTemplates: false, skipUnspecializedTemplates: true), + ); + } + + public function testFindLeakSkippingScanReturnsTheFirstLeakInDocumentOrder(): void + { + // The pruning scan must report the same FIRST leak the plain scan does — a + // later sibling must never overwrite it. + $first = new StaticCall(new Name('self'), new Identifier('one')); + $first->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + $second = new StaticCall(new Name('self'), new Identifier('two')); + $second->setAttribute(self::ARGS_MARKER, [new Identifier('int')]); + $body = [new Expression($first), new Expression($second)]; + + self::assertSame($first, GenericMarkerLeakGuard::findLeak($body, skipUnspecializedTemplates: true)); + self::assertSame($first, GenericMarkerLeakGuard::findLeak($body)); + } + public function testLeakMessageNamesTheLabelTheLineAndTheCode(): void { $call = new FuncCall(new Variable('inner'), [], ['startLine' => 7]); diff --git a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php index d9d8a294..5d329c8f 100644 --- a/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericMethodIntegrationTest.php @@ -1894,6 +1894,25 @@ public function testErasablePlainCallerForwardReusesLoweredMember(): void } } + #[RunInSeparateProcess] + public function testTwoHopOwnTemplateForwardChainCompilesAndRuns(): void + { + // `go` → `self::a::` whose grounded body forwards `self::b::`: the + // drain re-grounds the appended member with the spec's own identity, so the + // chain bottoms out on the spec. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/generic_class_method_forward_chain/source', + 'genmethod-forward-chain', + ); + try { + $fixture->registerAutoload('App\\MethodForwardChain'); + $runtime = require __DIR__ . '/../../fixture/compile/generic_class_method_forward_chain/verify/runtime.php'; + $runtime($fixture); + } finally { + $fixture->cleanup(); + } + } + #[RunInSeparateProcess] public function testStaticInheritedTurbofishLandsOnTheCallingSpec(): void { diff --git a/test/fixture/check/method_turbofish_two_hop_clean/source/Use.xphp b/test/fixture/check/method_turbofish_two_hop_clean/source/Use.xphp new file mode 100644 index 00000000..739c6852 --- /dev/null +++ b/test/fixture/check/method_turbofish_two_hop_clean/source/Use.xphp @@ -0,0 +1,34 @@ +` appends a member whose body +// holds a now-concrete `self::b::` — the drain must re-ground the appended member +// (with the spec's own identity, so the in-spec site guard passes) until the chain +// bottoms out. Check must stay silent: the retained `a`/`b` templates inside its +// un-stripped spec clone are dispatch machinery, not leaks. +class Holder +{ + /** @return array */ + public function go(T $v): array + { + return self::a::($v); + } + + /** @return array */ + public static function a(U $x): array + { + return self::b::($x); + } + + /** @return array */ + public static function b(V $x): array + { + return [$x, $x]; + } +} + +$h = new Holder::(); +$r = $h->go(9); diff --git a/test/fixture/check/method_turbofish_warning_same_line/source/Use.xphp b/test/fixture/check/method_turbofish_warning_same_line/source/Use.xphp new file mode 100644 index 00000000..c3f7e985 --- /dev/null +++ b/test/fixture/check/method_turbofish_warning_same_line/source/Use.xphp @@ -0,0 +1,48 @@ + +{ + public function __construct(public readonly P $v) + { + } +} + +class Producer +{ + public function __construct(public readonly mixed $seed) + { + } +} + +final class Book +{ +} + +// A WARNING and a deferred turbofish share one source line: the position dedupe is +// severity-aware, so the warning must not suppress grounding — the bound violation +// nested in gen's grounded body still surfaces (check-green here while compile +// rejects would be the dangerous direction). +final class Holder +{ + public function go(T $v): mixed + { + $x = [new Producer::(1), self::gen::($v)]; return $x; + } + + public static function gen(U $x): mixed + { + return new Pair::($x); + } +} + +$h = new Holder::(); +$h->go(1); diff --git a/test/fixture/compile/generic_class_method_forward_chain/source/Use.xphp b/test/fixture/compile/generic_class_method_forward_chain/source/Use.xphp new file mode 100644 index 00000000..739c6852 --- /dev/null +++ b/test/fixture/compile/generic_class_method_forward_chain/source/Use.xphp @@ -0,0 +1,34 @@ +` appends a member whose body +// holds a now-concrete `self::b::` — the drain must re-ground the appended member +// (with the spec's own identity, so the in-spec site guard passes) until the chain +// bottoms out. Check must stay silent: the retained `a`/`b` templates inside its +// un-stripped spec clone are dispatch machinery, not leaks. +class Holder +{ + /** @return array */ + public function go(T $v): array + { + return self::a::($v); + } + + /** @return array */ + public static function a(U $x): array + { + return self::b::($x); + } + + /** @return array */ + public static function b(V $x): array + { + return [$x, $x]; + } +} + +$h = new Holder::(); +$r = $h->go(9); diff --git a/test/fixture/compile/generic_class_method_forward_chain/verify/runtime.php b/test/fixture/compile/generic_class_method_forward_chain/verify/runtime.php new file mode 100644 index 00000000..68bc5b72 --- /dev/null +++ b/test/fixture/compile/generic_class_method_forward_chain/verify/runtime.php @@ -0,0 +1,23 @@ +targetDir . '/Use.php'; + + Assert::assertSame([9, 9], $r); + + $grounded = array_values(array_filter( + get_class_methods($h), + static fn (string $m): bool => str_starts_with($m, 'a_T_') || str_starts_with($m, 'b_T_'), + )); + Assert::assertCount(2, $grounded, 'both hops grounded onto the spec'); +}; diff --git a/test/fixture/compile/generic_class_static_inherited_turbofish/source/Use.xphp b/test/fixture/compile/generic_class_static_inherited_turbofish/source/Use.xphp index 64747675..493bfbe0 100644 --- a/test/fixture/compile/generic_class_static_inherited_turbofish/source/Use.xphp +++ b/test/fixture/compile/generic_class_static_inherited_turbofish/source/Use.xphp @@ -12,6 +12,15 @@ class Base { /** @return array */ public static function gen(U $x): array + { + // A further hop DECLARED ON THE ANCESTOR: the grounded member lives on the + // Holder spec, so its drained body re-grounds `self::genB::` there too — + // the ancestor chain grounds hop by hop, not just one level. + return self::genB::($x); + } + + /** @return array */ + public static function genB(V $x): array { return [$x, $x]; } diff --git a/test/fixture/compile/generic_class_static_inherited_turbofish/verify/runtime.php b/test/fixture/compile/generic_class_static_inherited_turbofish/verify/runtime.php index bdebf578..f458201a 100644 --- a/test/fixture/compile/generic_class_static_inherited_turbofish/verify/runtime.php +++ b/test/fixture/compile/generic_class_static_inherited_turbofish/verify/runtime.php @@ -17,18 +17,22 @@ Assert::assertSame([3, 3], $r1); Assert::assertSame('s', $r2); + // Both hops of the ancestor-declared chain (`gen` → `genB`) ground onto the + // Holder spec itself. $genMembers = array_values(array_filter( get_class_methods($h), - static fn (string $m): bool => str_starts_with($m, 'gen_T_'), + static fn (string $m): bool => str_starts_with($m, 'gen_T_') || str_starts_with($m, 'genB_T_'), )); - Assert::assertCount(1, $genMembers); - Assert::assertSame( - get_class($h), - (new ReflectionMethod($h, $genMembers[0]))->getDeclaringClass()->getName(), - 'member declared on the Holder spec itself', - ); + Assert::assertCount(2, $genMembers); + foreach ($genMembers as $member) { + Assert::assertSame( + get_class($h), + (new ReflectionMethod($h, $member))->getDeclaringClass()->getName(), + 'member declared on the Holder spec itself', + ); + } Assert::assertSame([], array_values(array_filter( get_class_methods($b), - static fn (string $m): bool => str_starts_with($m, 'gen_T_'), + static fn (string $m): bool => str_starts_with($m, 'gen_T_') || str_starts_with($m, 'genB_T_'), )), 'the Base spec grew no grounded member'); }; From c1f76a9fad25858eda1bea8be2c34fdbdcc05cfc Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 26 Jul 2026 21:47:23 +0000 Subject: [PATCH 09/46] refactor(monomorphize): reattach constructor docblock and skip templates in grounding pre-scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two no-behavior-change cleanups surfaced by review of the grounding pass: The constructor's `@param $diagnostics` docblock had been pushed away from `__construct` when the retained-state properties were inserted between them, leaving it orphaned (documenting nothing, and the constructor undocumented at its declaration). Move it back directly above the constructor. The cheap pre-scan in groundSpecializedClass called findLeak without `skipUnspecializedTemplates: true`, unlike the check-mode backstop 30 lines below. In check mode a spec clone keeps its unstripped generic-method templates, whose bodies carry markers the grounding walk already skips — so the pre-scan never took its "nothing to do" early exit for any spec declaring a generic method, running a redundant full walk. Aligning the flag restores the fast path; output is identical either way (verified: flipping the flag leaves the whole check/method suite green). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/GenericMethodCompiler.php | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index e4a83833..7d7e9da4 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -116,12 +116,6 @@ final class GenericMethodCompiler */ private const MAX_METHOD_SPECIALIZATION_HOPS = 16; - /** - * @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. - */ /** * Phase-1a state retained for the post-specialization grounding pass * ({@see groundSpecializedClass}). `process()` strips generic templates from the @@ -137,6 +131,12 @@ final class GenericMethodCompiler /** @var array class template FQN => source ast key (filepath), for grounding-time diagnostics */ private array $classSourceByFqn = []; + /** + * @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, @@ -365,7 +365,16 @@ public function groundSpecializedClass( return []; } // Cheap pre-scan: most specs carry no marker; skip the visitor entirely then. - if (GenericMarkerLeakGuard::findLeak($specialized, includeClosureTemplates: false) === null) { + // skipUnspecializedTemplates matches the check-mode backstop below: in check + // mode a spec clone retains its (unstripped) generic-method templates, whose + // bodies carry call markers that the grounding walk deliberately skips — without + // this flag the pre-scan would see those and never take the cheap exit for any + // spec that declares a generic method. + if (GenericMarkerLeakGuard::findLeak( + $specialized, + includeClosureTemplates: false, + skipUnspecializedTemplates: true, + ) === null) { return []; } From a31a2a33f84f71d55792240c7f361dbcb91a1bb5 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 27 Jul 2026 06:36:34 +0000 Subject: [PATCH 10/46] docs: clarify that forwarding a method-level type parameter is unsupported MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The turbofish caveats described the method-level-parameter forward failure as happening "to a non-erasable target", which wrongly implied an erasable target would work. It doesn't: forwarding a method-level parameter (`$this->dup::` inside `probe`) fails regardless of the target, because a generic method is specialized before its class, so `W` has no concrete value where the forward would be grounded. A non-erasable target reports `xphp.unspecializable_self_call`; an erasable one leaks and reports `xphp.unspecialized_generic_leak` — neither is supported. State that plainly in both docs/syntax/methods-and-functions.md and docs/caveats.md, and note the underlying reason (class parameters become concrete at class specialization; method parameters don't). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/caveats.md | 9 ++++++--- docs/syntax/methods-and-functions.md | 14 +++++++++----- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/caveats.md b/docs/caveats.md index dec305f9..b565be60 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -830,9 +830,12 @@ class Holder The late-bound `static::` / `parent::` spellings (resolving them statically could silently re-route a subclass or parent dispatch — rejecting loudly is the contract), a forward to a **bare top-level** (namespace-less) generic function from inside a -generic class, and a **method-level** parameter forwarded to a non-erasable target -(`$this->dup::` inside `probe` — reported precisely as -`xphp.unspecializable_self_call`, since no specialization ever grounds `W`). +generic class, and a **method-level** parameter forwarded to any generic method +(`$this->dup::` inside `probe`). A method-level parameter can't be forwarded +because a generic method is specialized before its class, so `W` has no concrete +value where the forward would be grounded — a non-erasable target reports +`xphp.unspecializable_self_call`, an erasable one `xphp.unspecialized_generic_leak`, +but neither is supported. A **strictly-growing** forward chain is rejected as non-convergent rather than compiled forever: diff --git a/docs/syntax/methods-and-functions.md b/docs/syntax/methods-and-functions.md index fa85b3a5..5da8b69b 100644 --- a/docs/syntax/methods-and-functions.md +++ b/docs/syntax/methods-and-functions.md @@ -129,11 +129,15 @@ build time instead of fataling at runtime with "Call to undefined method". - > ⚠️ Forwarding an **enclosing class type parameter** (`$this->dup::` / `self::gen::` / `Maker::wrap::` inside `Box`, or `identity::` inside a generic function `wrap`) - grounds per specialization and runs. Forwarding a **method-level** - parameter to a non-erasable target (`$this->dup::` inside - `probe`) stays a compile error (`xphp.unspecializable_self_call`), - as do targets on a *different* generic template and the - `static::`/`parent::` spellings. See + grounds per specialization and runs — the class parameter becomes + concrete when the class specializes. Forwarding a **method-level** + parameter (`$this->dup::` inside `probe`) does **not**: a generic + method is specialized before its class, so `W` has no concrete value + where the forward would be grounded. It is a compile error regardless + of the target — `xphp.unspecializable_self_call` for a non-erasable + target, `xphp.unspecialized_generic_leak` for an erasable one. + Targets on a *different* generic template and the `static::`/`parent::` + spellings are rejected too. See [caveats](../caveats.md#generic-turbofish-grounded-by-an-enclosing-type-parameter). ## See also From 868317d0c545714611cb143cbc2da5704dd9a6b9 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 27 Jul 2026 21:37:21 +0000 Subject: [PATCH 11/46] ci(mutation): run mutation tests only when src/ or test/ changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Infection is the slowest CI job (full coverage run + thousands of mutants), and it can only change outcome when the mutated code (src/) or the tests that kill mutants (test/) change. Gate it: a `changes` job (dorny/paths-filter) detects whether src/ or test/ was touched, and the `infection` job runs only when it was — so a docs- or config-only PR skips mutation entirely. The `needs: phpunit` ordering is preserved (mutation still waits for unit tests). --- .github/workflows/ci-core.yml | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index 18dc601f..53090b7e 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -21,6 +21,27 @@ env: SHARD_TOTAL: 10 jobs: + # Detect whether this PR/push touches code that mutation testing covers. + # Infection is expensive, so the `infection` job below runs only when src/ + # or test/ changed — a docs-only or config-only change skips it. + changes: + name: Detect code changes + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + code: ${{ steps.filter.outputs.code }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + code: + - 'src/**' + - 'test/**' + phpunit: # 8.4 is the supported/default runtime and runs the full suite minus the # `php85` group; a dedicated 8.5 container runs only that group, which @@ -133,7 +154,10 @@ jobs: mutation-coverage: name: Mutation coverage (generate once) runs-on: ubuntu-latest - needs: phpunit + # Only after unit tests pass, and only when src/ or test/ changed + # (see the `changes` job) — mutation is skipped for docs/config-only work. + needs: [phpunit, changes] + if: needs.changes.outputs.code == 'true' steps: - uses: actions/checkout@v4 From aa26911be11c1e11e21e3d5e28b3a4db0caf72fa Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 26 Jul 2026 12:05:25 +0000 Subject: [PATCH 12/46] build(commitlint): enforce Conventional Commit messages with the same tool locally and in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit subjects had been following the Conventional Commits pattern (type(scope): lowercase subject) by convention only; nothing rejected a stray unprefixed message. Adopt commitlint as the single enforcement tool: rules live in commitlint.config.mjs (the stock config-conventional preset, which matches the repo history), versions are pinned by package-lock.json, and a new docker compose `node` service runs it — so contributors need docker but no host Node toolchain. The tracked .githooks/commit-msg hook pipes the message (comment lines stripped, worktree-safe) into commitlint via that service, and composer install/update wires the hook up automatically through core.hooksPath. CONTRIBUTING documents the format with real examples from the history. Co-Authored-By: Claude Fable 5 --- .githooks/commit-msg | 26 + .gitignore | 1 + CONTRIBUTING.md | 25 + commitlint.config.mjs | 15 + composer.json | 8 + docker-compose.yml | 11 + package-lock.json | 1638 +++++++++++++++++++++++++++++++++++++++++ package.json | 9 + 8 files changed, 1733 insertions(+) create mode 100755 .githooks/commit-msg create mode 100644 commitlint.config.mjs create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 00000000..849145b1 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,26 @@ +#!/bin/sh +# Commit-message lint: the SAME tool CI runs — commitlint over +# commitlint.config.mjs — executed through the docker compose `node` service, +# so contributors need docker but no host Node toolchain. +# +# Installed by `composer install` via `git config core.hooksPath .githooks`. +# The message is piped on stdin (comment lines stripped, as git would on +# commit), so no path mapping into the container is needed and the hook works +# from worktrees too. node_modules is npm-ci'd on first use from the lockfile. + +msg_file="$1" + +if ! command -v docker > /dev/null 2>&1; then + echo "commit-msg: docker is required to lint the commit message (compose service 'node')." >&2 + echo " rules: commitlint.config.mjs (Conventional Commits) — CI enforces the same check." >&2 + exit 1 +fi + +top=$(git rev-parse --show-toplevel) + +sed -e 's/\r$//' -e '/^#/d' "$msg_file" \ + | docker compose --project-directory "$top" run --rm -T --no-deps node sh -c ' + test -x node_modules/.bin/commitlint \ + || npm ci --no-audit --no-fund --loglevel=error + exec node_modules/.bin/commitlint + ' diff --git a/.gitignore b/.gitignore index 8ad0cf23..258caed6 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ /core/.xphp-cache/ docker-compose.override.yml .phpunit.result.cache +node_modules/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e5ced8be..53c851ea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,30 @@ # Contributing +## Commit messages + +Commits follow [Conventional Commits](https://www.conventionalcommits.org): + +``` +type(scope): lowercase subject +``` + +Types: `build` `chore` `ci` `docs` `feat` `fix` `perf` `refactor` `revert` +`style` `test`. The scope is the component the change lives in +(`monomorphize`, `specializer`, `parser`, `cli`, ...); bare `type:` is fine +for cross-cutting changes. Examples from the history: + +``` +feat(monomorphize): ground enclosing-param method turbofish per class specialization +fix(cli): locate Composer autoloader when installed as a dependency +docs: refresh docs/ tree with syntax tour, caveats, errors +``` + +CI rejects a PR whose commits don't conform: `commitlint` (pinned by +`package-lock.json`) over `commitlint.config.mjs`. Locally, `composer +install` wires up a `commit-msg` hook (`.githooks/`) that runs the **same +tool** through the docker compose `node` service at commit time — docker is +required, a host Node toolchain is not. + ## Test ```bash diff --git a/commitlint.config.mjs b/commitlint.config.mjs new file mode 100644 index 00000000..290fa12e --- /dev/null +++ b/commitlint.config.mjs @@ -0,0 +1,15 @@ +// Commit-message rules: Conventional Commits (https://www.conventionalcommits.org). +// One rule set, one tool, two entry points: +// - locally: .githooks/commit-msg runs commitlint through the docker compose +// `node` service (installed by `composer install`) +// - in CI: .github/workflows/commitlint.yml runs the same lockfile-pinned +// commitlint over every PR commit +// +// The stock preset already matches this repo's history: +// type(scope): lowercase subject +// with types build/chore/ci/docs/feat/fix/perf/refactor/revert/style/test, an +// optional free-form scope (monomorphize, specializer, parser, cli, ...), a +// 100-char header cap, and merge/revert/fixup subjects ignored. +export default { + extends: ['@commitlint/config-conventional'], +}; diff --git a/composer.json b/composer.json index 553e08ae..f629e199 100644 --- a/composer.json +++ b/composer.json @@ -51,5 +51,13 @@ "allow-plugins": { "infection/extension-installer": true } + }, + "scripts": { + "post-install-cmd": "@git:hooks", + "post-update-cmd": "@git:hooks", + "git:hooks": "git rev-parse --git-dir > /dev/null 2>&1 && git config core.hooksPath .githooks || true" + }, + "scripts-descriptions": { + "git:hooks": "Point git at the tracked .githooks/ directory (commit-msg lint); no-op outside a git checkout." } } diff --git a/docker-compose.yml b/docker-compose.yml index 31851af5..67ab8dda 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,5 +22,16 @@ services: service: php entrypoint: [ "/opt/app/bin/xphp" ] + # Node runtime for commit-message linting: the .githooks/commit-msg hook runs + # commitlint through this service, so contributors need docker but no host + # Node toolchain — and local linting uses the exact tool CI runs. One-shot: + # docker compose run --rm node npx commitlint --help + node: + image: node:24-alpine + working_dir: /opt/app + volumes: + - ./:/opt/app + command: [ "tail", "-f", "/dev/null" ] + volumes: composer_cache: ~ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..b6b7d3b6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1638 @@ +{ + "name": "xphp-dev-tools", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "xphp-dev-tools", + "devDependencies": { + "@commitlint/cli": "^19.8.1", + "@commitlint/config-conventional": "^19.8.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@commitlint/cli": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-19.8.1.tgz", + "integrity": "sha512-LXUdNIkspyxrlV6VDHWBmCZRtkEVRpBKxi2Gtw3J54cGWhLCTouVD/Q6ZSaSvd2YaDObWK8mDjrz3TIKtaQMAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/format": "^19.8.1", + "@commitlint/lint": "^19.8.1", + "@commitlint/load": "^19.8.1", + "@commitlint/read": "^19.8.1", + "@commitlint/types": "^19.8.1", + "tinyexec": "^1.0.0", + "yargs": "^17.0.0" + }, + "bin": { + "commitlint": "cli.js" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/config-conventional": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-19.8.1.tgz", + "integrity": "sha512-/AZHJL6F6B/G959CsMAzrPKKZjeEiAVifRyEwXxcT6qtqbPwGw+iQxmNS+Bu+i09OCtdNRW6pNpBvgPrtMr9EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.8.1", + "conventional-changelog-conventionalcommits": "^7.0.2" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/config-validator": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-19.8.1.tgz", + "integrity": "sha512-0jvJ4u+eqGPBIzzSdqKNX1rvdbSU1lPNYlfQQRIFnBgLy26BtC0cFnr7c/AyuzExMxWsMOte6MkTi9I3SQ3iGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.8.1", + "ajv": "^8.11.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/ensure": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-19.8.1.tgz", + "integrity": "sha512-mXDnlJdvDzSObafjYrOSvZBwkD01cqB4gbnnFuVyNpGUM5ijwU/r/6uqUmBXAAOKRfyEjpkGVZxaDsCVnHAgyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.8.1", + "lodash.camelcase": "^4.3.0", + "lodash.kebabcase": "^4.1.1", + "lodash.snakecase": "^4.1.1", + "lodash.startcase": "^4.4.0", + "lodash.upperfirst": "^4.3.1" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/execute-rule": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-19.8.1.tgz", + "integrity": "sha512-YfJyIqIKWI64Mgvn/sE7FXvVMQER/Cd+s3hZke6cI1xgNT/f6ZAz5heND0QtffH+KbcqAwXDEE1/5niYayYaQA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/format": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-19.8.1.tgz", + "integrity": "sha512-kSJj34Rp10ItP+Eh9oCItiuN/HwGQMXBnIRk69jdOwEW9llW9FlyqcWYbHPSGofmjsqeoxa38UaEA5tsbm2JWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.8.1", + "chalk": "^5.3.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/is-ignored": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-19.8.1.tgz", + "integrity": "sha512-AceOhEhekBUQ5dzrVhDDsbMaY5LqtN8s1mqSnT2Kz1ERvVZkNihrs3Sfk1Je/rxRNbXYFzKZSHaPsEJJDJV8dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.8.1", + "semver": "^7.6.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/lint": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-19.8.1.tgz", + "integrity": "sha512-52PFbsl+1EvMuokZXLRlOsdcLHf10isTPlWwoY1FQIidTsTvjKXVXYb7AvtpWkDzRO2ZsqIgPK7bI98x8LRUEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/is-ignored": "^19.8.1", + "@commitlint/parse": "^19.8.1", + "@commitlint/rules": "^19.8.1", + "@commitlint/types": "^19.8.1" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/load": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-19.8.1.tgz", + "integrity": "sha512-9V99EKG3u7z+FEoe4ikgq7YGRCSukAcvmKQuTtUyiYPnOd9a2/H9Ak1J9nJA1HChRQp9OA/sIKPugGS+FK/k1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/config-validator": "^19.8.1", + "@commitlint/execute-rule": "^19.8.1", + "@commitlint/resolve-extends": "^19.8.1", + "@commitlint/types": "^19.8.1", + "chalk": "^5.3.0", + "cosmiconfig": "^9.0.0", + "cosmiconfig-typescript-loader": "^6.1.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "lodash.uniq": "^4.5.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/message": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-19.8.1.tgz", + "integrity": "sha512-+PMLQvjRXiU+Ae0Wc+p99EoGEutzSXFVwQfa3jRNUZLNW5odZAyseb92OSBTKCu+9gGZiJASt76Cj3dLTtcTdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/parse": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-19.8.1.tgz", + "integrity": "sha512-mmAHYcMBmAgJDKWdkjIGq50X4yB0pSGpxyOODwYmoexxxiUCy5JJT99t1+PEMK7KtsCtzuWYIAXYAiKR+k+/Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.8.1", + "conventional-changelog-angular": "^7.0.0", + "conventional-commits-parser": "^5.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/read": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-19.8.1.tgz", + "integrity": "sha512-03Jbjb1MqluaVXKHKRuGhcKWtSgh3Jizqy2lJCRbRrnWpcM06MYm8th59Xcns8EqBYvo0Xqb+2DoZFlga97uXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/top-level": "^19.8.1", + "@commitlint/types": "^19.8.1", + "git-raw-commits": "^4.0.0", + "minimist": "^1.2.8", + "tinyexec": "^1.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/resolve-extends": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-19.8.1.tgz", + "integrity": "sha512-GM0mAhFk49I+T/5UCYns5ayGStkTt4XFFrjjf0L4S26xoMTSkdCf9ZRO8en1kuopC4isDFuEm7ZOm/WRVeElVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/config-validator": "^19.8.1", + "@commitlint/types": "^19.8.1", + "global-directory": "^4.0.1", + "import-meta-resolve": "^4.0.0", + "lodash.mergewith": "^4.6.2", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/rules": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-19.8.1.tgz", + "integrity": "sha512-Hnlhd9DyvGiGwjfjfToMi1dsnw1EXKGJNLTcsuGORHz6SS9swRgkBsou33MQ2n51/boIDrbsg4tIBbRpEWK2kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/ensure": "^19.8.1", + "@commitlint/message": "^19.8.1", + "@commitlint/to-lines": "^19.8.1", + "@commitlint/types": "^19.8.1" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/to-lines": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-19.8.1.tgz", + "integrity": "sha512-98Mm5inzbWTKuZQr2aW4SReY6WUukdWXuZhrqf1QdKPZBCCsXuG87c+iP0bwtD6DBnmVVQjgp4whoHRVixyPBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/top-level": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-19.8.1.tgz", + "integrity": "sha512-Ph8IN1IOHPSDhURCSXBz44+CIu+60duFwRsg6HqaISFHQHbmBtxVw4ZrFNIYUzEP7WwrNPxa2/5qJ//NK1FGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^7.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/types": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-19.8.1.tgz", + "integrity": "sha512-/yCrWGCoA1SVKOks25EGadP9Pnj0oAIHGpl2wH2M2Y46dPM2ueb8wyCVOD7O3WCTkaJ0IkKvzhl1JY7+uCT2Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/conventional-commits-parser": "^5.0.0", + "chalk": "^5.3.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@types/conventional-commits-parser": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/conventional-commits-parser/-/conventional-commits-parser-5.0.2.tgz", + "integrity": "sha512-BgT2szDXnVypgpNxOK8aL5SGjUdaQbC++WZNjF1Qge3Og2+zhHj+RWhmehLhYyvQwqAmvezruVfOf8+3m74W+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-ify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", + "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", + "dev": true, + "license": "MIT" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/compare-func": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", + "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-ify": "^1.0.0", + "dot-prop": "^5.1.0" + } + }, + "node_modules/conventional-changelog-angular": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz", + "integrity": "sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/conventional-changelog-conventionalcommits": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-7.0.2.tgz", + "integrity": "sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/conventional-commits-parser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz", + "integrity": "sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-text-path": "^2.0.0", + "JSONStream": "^1.3.5", + "meow": "^12.0.1", + "split2": "^4.0.0" + }, + "bin": { + "conventional-commits-parser": "cli.mjs" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cosmiconfig-typescript-loader": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.3.0.tgz", + "integrity": "sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jiti": "2.6.1" + }, + "engines": { + "node": ">=v18" + }, + "peerDependencies": { + "@types/node": "*", + "cosmiconfig": ">=9", + "typescript": ">=5" + } + }, + "node_modules/dargs": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/dargs/-/dargs-8.1.0.tgz", + "integrity": "sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/find-up": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz", + "integrity": "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^7.2.0", + "path-exists": "^5.0.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/git-raw-commits": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-4.0.0.tgz", + "integrity": "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==", + "deprecated": "Deprecated and no longer maintained. Use @conventional-changelog/git-client instead.", + "dev": true, + "license": "MIT", + "dependencies": { + "dargs": "^8.0.0", + "meow": "^12.0.1", + "split2": "^4.0.0" + }, + "bin": { + "git-raw-commits": "cli.mjs" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "4.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-text-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-2.0.0.tgz", + "integrity": "sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "text-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/JSONStream": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", + "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", + "dev": true, + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "jsonparse": "^1.2.0", + "through": ">=2.2.7 <3" + }, + "bin": { + "JSONStream": "bin.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.kebabcase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", + "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.startcase": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", + "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.upperfirst": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", + "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/meow": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", + "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-extensions": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-2.4.0.tgz", + "integrity": "sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..372e35e3 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "xphp-dev-tools", + "private": true, + "description": "Dev-only Node toolchain: commit-message linting (commitlint). The PHP package proper is defined in composer.json.", + "devDependencies": { + "@commitlint/cli": "^19.8.1", + "@commitlint/config-conventional": "^19.8.1" + } +} From a04dba90c864f3bfc055e381c8f9d2c5a9e2d469 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 26 Jul 2026 12:05:27 +0000 Subject: [PATCH 13/46] ci(commitlint): gate pull requests on Conventional Commit messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run the lockfile-pinned commitlint over every commit in the PR range (full-history checkout), with the same commitlint.config.mjs the local commit-msg hook uses — one tool, one version, one rule set in both places. The CI job is the authoritative check backing the local hook, which contributors can bypass with --no-verify. Co-Authored-By: Claude Fable 5 --- .github/workflows/commitlint.yml | 38 ++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/commitlint.yml diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml new file mode 100644 index 00000000..f20e1960 --- /dev/null +++ b/.github/workflows/commitlint.yml @@ -0,0 +1,38 @@ +name: Commitlint + +# Gate every PR on Conventional Commit messages. Same tool, same version, same +# config as the local .githooks/commit-msg hook: commitlint pinned by +# package-lock.json, rules in commitlint.config.mjs. + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + commitlint: + name: Conventional commit messages + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # commitlint needs the full PR range, not a shallow tip. + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + # Keep in step with the compose `node` service image. + node-version: 24 + cache: npm + - name: Install commitlint (lockfile-pinned) + run: npm ci --no-audit --no-fund + - name: Lint PR commit messages + run: > + npx --no-install commitlint + --from ${{ github.event.pull_request.base.sha }} + --to ${{ github.event.pull_request.head.sha }} + --verbose From 799289fdc5f40d8c5867b052fae84e6b2a61f90a Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 27 Jul 2026 21:29:00 +0000 Subject: [PATCH 14/46] fix(commitlint): reinstall stale node_modules and require the compose plugin Two robustness gaps in the commit-msg hook found in review: - The install guard only reinstalled when the commitlint binary was absent, so a version bump a collaborator pulls in (new package-lock.json, untouched node_modules) left the hook linting with the old version while CI used the pinned one -- defeating the "same version everywhere" goal. Reinstall when node_modules is missing OR the lockfile is newer than the installed binary; the already-fresh common case still skips npm ci. - The hook checked for `docker` but then runs `docker compose`, so a box with docker-engine but no Compose v2 plugin hit Docker's own cryptic error. Add a `docker compose version` preflight with a useful message. --- .githooks/commit-msg | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/.githooks/commit-msg b/.githooks/commit-msg index 849145b1..046cef6b 100755 --- a/.githooks/commit-msg +++ b/.githooks/commit-msg @@ -6,7 +6,8 @@ # Installed by `composer install` via `git config core.hooksPath .githooks`. # The message is piped on stdin (comment lines stripped, as git would on # commit), so no path mapping into the container is needed and the hook works -# from worktrees too. node_modules is npm-ci'd on first use from the lockfile. +# from worktrees too. node_modules is (re)installed from the lockfile whenever +# it's missing or stale, so the local commitlint always matches the pinned one. msg_file="$1" @@ -16,11 +17,26 @@ if ! command -v docker > /dev/null 2>&1; then exit 1 fi +# `command -v docker` isn't enough: this hook runs `docker compose run`, which +# needs the Compose v2 plugin. Without it, Docker prints its own cryptic +# "'compose' is not a docker command" — surface a useful message instead. +if ! docker compose version > /dev/null 2>&1; then + echo "commit-msg: the Docker Compose v2 plugin is required ('docker compose' is unavailable)." >&2 + echo " rules: commitlint.config.mjs (Conventional Commits) — CI enforces the same check." >&2 + exit 1 +fi + top=$(git rev-parse --show-toplevel) +# Reinstall when node_modules is absent OR the lockfile is newer than the +# installed binary — a version bump a collaborator pulls in updates +# package-lock.json but leaves node_modules untouched, and a stale local +# commitlint would silently disagree with CI. `npm ci` is otherwise skipped so +# the common case (already installed) stays fast. sed -e 's/\r$//' -e '/^#/d' "$msg_file" \ | docker compose --project-directory "$top" run --rm -T --no-deps node sh -c ' - test -x node_modules/.bin/commitlint \ - || npm ci --no-audit --no-fund --loglevel=error + if [ ! -x node_modules/.bin/commitlint ] || [ package-lock.json -nt node_modules/.bin/commitlint ]; then + npm ci --no-audit --no-fund --loglevel=error + fi exec node_modules/.bin/commitlint ' From d278204a423afb933478744ed34efdf74111ab96 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 27 Jul 2026 21:29:08 +0000 Subject: [PATCH 15/46] fix(commitlint): run the node service behind a compose profile The node service carried a `tail -f /dev/null` keepalive, so a bare `docker compose up` started and kept it running. That keepalive was also pointless here: the commit-msg hook (and CI) invoke it with `docker compose run`, which supplies its own command and never needs the service pre-started. Put it behind a `commitlint` profile and drop the keepalive -- `docker compose run node` still starts the profiled service on demand, and it no longer shows up in the default stack. --- docker-compose.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 67ab8dda..36a29892 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,12 +26,16 @@ services: # commitlint through this service, so contributors need docker but no host # Node toolchain — and local linting uses the exact tool CI runs. One-shot: # docker compose run --rm node npx commitlint --help + # Runs commitlint for the commit-msg hook and CI. Behind a profile so a bare + # `docker compose up` doesn't start it; the hook targets it explicitly with + # `docker compose run node`, which starts a profiled service on demand and + # supplies its own command (no keepalive needed). node: image: node:24-alpine working_dir: /opt/app volumes: - ./:/opt/app - command: [ "tail", "-f", "/dev/null" ] + profiles: [ "commitlint" ] volumes: composer_cache: ~ From db98cbb039340a8339051e604ff4c7f4884984ba Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 27 Jul 2026 06:48:19 +0000 Subject: [PATCH 16/46] docs(comparison): note the absence of type-argument inference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feature grid compared declaration surface, bounds, variance, and runtime semantics, but never stated a call-site divergence: xphp has no type-argument inference — the ::<> turbofish is mandatory on every generic call, and omitting it is a compile error (needed to pick a specialization under monomorphization). TypeScript, Kotlin, and Rust all infer; Rust is the sharp parallel, borrowing the same ::<> spelling but using it only as the disambiguation fallback over default inference. The bound-erasure RFC has no inference either, but keeps the turbofish optional (omitting runs unvalidated) rather than required. Add a grid row (red cross for xphp and the RFC, both genuinely lacking inference) plus a short note capturing the mandatory-vs-optional-vs- inferred spread. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/guides/comparison.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/guides/comparison.md b/docs/guides/comparison.md index 668ad6f0..2037cb9f 100644 --- a/docs/guides/comparison.md +++ b/docs/guides/comparison.md @@ -28,6 +28,7 @@ than erasure can. | Generic functions / methods | ✅ | ✅ | ✅ | ✅ | ✅ | | Generic closures + arrow functions | ✅ | ✅ | ✅ | ✅ | ✅ | | Typed closure signatures (`Closure(int): bool`) | ✅ (erases to `\Closure`; literal conformance checked at compile time) | ✅ (runtime-lenient) | ✅ (function types) | ✅ (`(Int) -> Bool`) | ✅ (`Fn(i32) -> bool`) | +| Type-argument inference (call without `::<>`) | ❌ (explicit turbofish required) | ❌ (turbofish optional; omitting runs unvalidated) | ✅ | ✅ | ✅ (turbofish is the fallback) | | Upper bounds | ✅ | ✅ | ✅ | ✅ | ✅ | | Multiple bounds (intersection) | ✅ | ✅ | ✅ | ✅ | ✅ | | Union bounds + DNF | ✅ | ✅ | ✅ | ❌ (intersection only via `where`) | n/a | @@ -53,6 +54,19 @@ mark features that simply can't exist under bound erasure: there are no specialized classes at runtime, so subtype edges, reified-T operations, and a wildcard sigil all lose their meaning. +**Type-argument inference.** No xphp generic call infers its type +arguments from the values passed — you always write the turbofish: +`identity::($x)`, `new Box::()`. Omitting it is a compile +error (`xphp.missing_type_argument`), because monomorphization needs +the concrete type to pick a specialization. TypeScript, Kotlin, and +Rust all infer instead. Rust is the closest comparison: xphp borrows +its `::<>` turbofish spelling exactly, but where Rust infers by +default and reaches for the turbofish only to disambiguate, xphp +makes it the only spelling. The bound-erasure RFC has no inference +either, yet diverges from xphp in the other direction — there the +turbofish is *optional*: omit it and the call runs unvalidated with +erased-to-`mixed` semantics rather than failing to compile. + ## Where the monomorphic and erasure paths diverge Three features fall out of the monomorphization model that the From 327acee2cbe52f946e8c417980e9431c2047c382 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 27 Jul 2026 18:03:26 +0000 Subject: [PATCH 17/46] docs(comparison): flag five features as supported-with-caveats, not fully supported MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An honest pass over the feature grid: five rows were marked a plain green check but have real, documented caveats, so they now carry a warning sign with the caveat named in the cell and detailed in a new "Supported with caveats" section (each linking to the full write-up in caveats.md): - Generic closures / arrows: no $this capture, no `static function` closures, and reflection/serializers see the dispatcher rewrite. - Typed closure signatures: parameter/return/property positions only, not a generic argument or bound. - Generic functions / methods: no inference, and a turbofish can't forward a method-level parameter, target another generic template, or use static::/parent::. - Declaration-site variance: violations inside trait-used methods go unchecked; class-level only. - Real subtype edges: some covariant upcasts of erased method-generics are unschedulable, and self-reintroducing derivations may not converge — the celebratory subsection now points at this too. No cell moved between present and absent; the grid just stops overstating these five as caveat-free. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/guides/comparison.md | 59 +++++++++++++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/docs/guides/comparison.md b/docs/guides/comparison.md index 2037cb9f..d3529c49 100644 --- a/docs/guides/comparison.md +++ b/docs/guides/comparison.md @@ -25,20 +25,20 @@ than erasure can. | Feature | xphp | RFC | TS | Kotlin | Rust | |------------------------------------------|-------------------------|------------------|------------------|---------------|-----------------------| | Generic classes / interfaces / traits | ✅ | ✅ | ✅ | ✅ | ✅ | -| Generic functions / methods | ✅ | ✅ | ✅ | ✅ | ✅ | -| Generic closures + arrow functions | ✅ | ✅ | ✅ | ✅ | ✅ | -| Typed closure signatures (`Closure(int): bool`) | ✅ (erases to `\Closure`; literal conformance checked at compile time) | ✅ (runtime-lenient) | ✅ (function types) | ✅ (`(Int) -> Bool`) | ✅ (`Fn(i32) -> bool`) | +| Generic functions / methods | ⚠️ (no inference; can't forward a method-level param, target another generic template, or use `static::`/`parent::`) | ✅ | ✅ | ✅ | ✅ | +| Generic closures + arrow functions | ⚠️ (no `$this` capture or `static function` closures; reflection/serializers see the dispatcher rewrite) | ✅ | ✅ | ✅ | ✅ | +| Typed closure signatures (`Closure(int): bool`) | ⚠️ (param/return/property only — not a generic arg or bound; erases to `\Closure`, literal conformance checked) | ✅ (runtime-lenient) | ✅ (function types) | ✅ (`(Int) -> Bool`) | ✅ (`Fn(i32) -> bool`) | | Type-argument inference (call without `::<>`) | ❌ (explicit turbofish required) | ❌ (turbofish optional; omitting runs unvalidated) | ✅ | ✅ | ✅ (turbofish is the fallback) | | Upper bounds | ✅ | ✅ | ✅ | ✅ | ✅ | | Multiple bounds (intersection) | ✅ | ✅ | ✅ | ✅ | ✅ | | Union bounds + DNF | ✅ | ✅ | ✅ | ❌ (intersection only via `where`) | n/a | | F-bounded recursion (`T : Box`) | ✅ | ✅ | ✅ | ✅ | ✅ | | Default type parameters | ✅ | ✅ | ✅ | ✅ | ✅ | -| Declaration-site variance (`out T` / `in T`) | ✅ | ✅ | ✅ | ✅ | ⚠️ inferred (lifetime-driven; PhantomData for unused type params) | +| Declaration-site variance (`out T` / `in T`) | ⚠️ (class-level only; violations inside trait-`use`d methods go unchecked) | ✅ | ✅ | ✅ | ⚠️ inferred (lifetime-driven; PhantomData for unused type params) | | Inner-template variance composition | ✅ | ✅ | ✅ | ✅ | ✅ | | Reified T at runtime | ✅ (via AOT) | ❌ (erased) | ❌ | ✅ (inline) | ✅ (monomorphic) | | `instanceof OriginalFqn` works | ✅ | ✅ (trivially: only one class exists at runtime) | n/a | n/a | n/a | -| Real subtype edges between specializations | ✅ | ❌ (erased) | n/a | n/a | n/a | +| Real subtype edges between specializations | ⚠️ (common case works; some covariant upcasts are unschedulable or may not converge) | ❌ (erased) | n/a | n/a | n/a | | Generic type aliases | ❌ | ❌ | ✅ | ✅ | ✅ | | Wildcard / `*` (use-site existential) | ⚠️ partial (via marker) | n/a (erased) | ⚠️ via `any` (bivariant escape hatch — loses type discipline) | ✅ (`Box<*>`) | n/a | | Use-site variance | ❌ | ❌ | ❌ | ✅ | n/a | @@ -110,6 +110,12 @@ runtime check. Erasure-based runtimes can't do this because their specializations don't exist as distinct classes. +> ⚠️ Not universal — see [supported with caveats](#supported-with-caveats). +> A covariant upcast to an interface with an *erased* element-consuming +> method can be unschedulable (a loud `xphp.unschedulable_covariant_upcast`, +> never wrong code), and a self-reintroducing derivation can fail to +> converge. + ### `instanceof OriginalFqn` works Every generic template emits a marker interface at the original FQN. @@ -118,6 +124,49 @@ and any other specialization, even though they're physically unrelated classes. You get the "polymorphic over T" mental model without losing instance checks. +## Supported with caveats + +The features marked ⚠️ for xphp in the grid work, but with limits worth +knowing before you lean on them. Each links to the full write-up (with a +reproduction and workaround) in [caveats](../caveats.md). + +- **Generic closures + arrow functions.** A generic closure/arrow can't + capture `$this` ([caveat](../caveats.md#this-capturing-arrows-and-closures-rejected)), + the `static function` closure form isn't supported + ([caveat](../caveats.md#static-closures-not-supported)), and — because + each call site is rewritten to a dispatcher closure — reflection and + closure serializers see the dispatcher's shape rather than your original + body ([caveat](../caveats.md#reflection-on-rewritten-generic-closures)). + Plain (non-`static`, non-`$this`) generic closures and arrows work. +- **Typed closure signatures.** Accepted only in parameter, return, and + property positions. A signature as a generic argument + (`Box`) or a bound is a compile error, and a signature + parameter can't be defaulted or untyped + ([caveat](../caveats.md#closure-signature-types-only-in-parameter-return-and-property-slots)). +- **Generic functions / methods.** The base feature is solid; *composition* + is where the gaps are. There's no type-argument inference — the turbofish + is mandatory (see the grid row). And a turbofish grounded by an enclosing + type parameter can't forward a *method-level* parameter, target a + *different* generic template, or use the `static::`/`parent::` spellings + ([caveat](../caveats.md#generic-turbofish-grounded-by-an-enclosing-type-parameter)). + Receiver-type tracking also gives up across branches that disagree on the + type, and on a local assigned from a free function + ([caveat](../caveats.md#branching-narrowing-precision-loss)). +- **Declaration-site variance.** Variance is enforced on methods declared + directly on the class, but a violation inside a **trait-`use`d** method + slips through unchecked + ([caveat](../caveats.md#variance-validator-and-trait-use)) — audit traits + on variant classes. Variance is class-level only; there's no + method/function-level variance. +- **Real subtype edges.** Emitted for the common case (and a genuine + strength — see above), but not universal: some covariant upcasts to an + interface with an erased element-consuming method are **unschedulable** + and fail loudly (`xphp.unschedulable_covariant_upcast`), a + self-reintroducing list-↔-map derivation can fail to converge, and a + covariant `array`-backed collection trips the optional PHPStan pass at + level 6+ + ([caveat](../caveats.md#covariant-array-backed-collections-trip-the-xphp-check-phpstan-pass)). + ## What's missing today The features marked ❌ in the grid above are conscious deferrals, not From 90927fa827965b89c0adb8ea0d9fb1eebcd6945e Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 27 Jul 2026 18:46:20 +0000 Subject: [PATCH 18/46] docs(comparison): correct RFC/Kotlin/TS marks, verified against real compilers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Honesty pass over the non-xphp columns, checked by compiling generic snippets in throwaway containers (TypeScript 7.0.2, Rust 1.97.1, Kotlin 1.4.10) rather than asserting from memory. The RFC column can't be run (no implementation exists) — its claims were checked against the RFC text. Corrections: - RFC "typed closure signatures" was a green check, but the bound-erased generics RFC has no structural closure/callable signature types (it lists them as future work). Now a red cross; PHP has only untyped callable / \Closure. (Verified against the RFC text.) - Kotlin reified T carried a plain check; `class Box` fails with "only type parameters of inline functions can be reified", so it's now a caveat (inline fun only). (Compiler-verified.) - Generic enums: neither Kotlin `enum class` nor a TS `enum` can be generic (compiler-verified); both rows keep their check but note the real mechanism (sealed classes / discriminated unions). Everything else in the TS, Rust, and Kotlin columns was confirmed correct by the same compile checks — declaration-site variance, variadic tuples, union bounds, sum types, type aliases, associated types, use-site variance, reified/monomorphic T, and specialization-needs-nightly all behave as the grid claims. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/guides/comparison.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/guides/comparison.md b/docs/guides/comparison.md index d3529c49..f1d0af8d 100644 --- a/docs/guides/comparison.md +++ b/docs/guides/comparison.md @@ -27,7 +27,7 @@ than erasure can. | Generic classes / interfaces / traits | ✅ | ✅ | ✅ | ✅ | ✅ | | Generic functions / methods | ⚠️ (no inference; can't forward a method-level param, target another generic template, or use `static::`/`parent::`) | ✅ | ✅ | ✅ | ✅ | | Generic closures + arrow functions | ⚠️ (no `$this` capture or `static function` closures; reflection/serializers see the dispatcher rewrite) | ✅ | ✅ | ✅ | ✅ | -| Typed closure signatures (`Closure(int): bool`) | ⚠️ (param/return/property only — not a generic arg or bound; erases to `\Closure`, literal conformance checked) | ✅ (runtime-lenient) | ✅ (function types) | ✅ (`(Int) -> Bool`) | ✅ (`Fn(i32) -> bool`) | +| Typed closure signatures (`Closure(int): bool`) | ⚠️ (param/return/property only — not a generic arg or bound; erases to `\Closure`, literal conformance checked) | ❌ (only untyped `callable` / `\Closure`; noted as future work) | ✅ (function types) | ✅ (`(Int) -> Bool`) | ✅ (`Fn(i32) -> bool`) | | Type-argument inference (call without `::<>`) | ❌ (explicit turbofish required) | ❌ (turbofish optional; omitting runs unvalidated) | ✅ | ✅ | ✅ (turbofish is the fallback) | | Upper bounds | ✅ | ✅ | ✅ | ✅ | ✅ | | Multiple bounds (intersection) | ✅ | ✅ | ✅ | ✅ | ✅ | @@ -36,14 +36,14 @@ than erasure can. | Default type parameters | ✅ | ✅ | ✅ | ✅ | ✅ | | Declaration-site variance (`out T` / `in T`) | ⚠️ (class-level only; violations inside trait-`use`d methods go unchecked) | ✅ | ✅ | ✅ | ⚠️ inferred (lifetime-driven; PhantomData for unused type params) | | Inner-template variance composition | ✅ | ✅ | ✅ | ✅ | ✅ | -| Reified T at runtime | ✅ (via AOT) | ❌ (erased) | ❌ | ✅ (inline) | ✅ (monomorphic) | +| Reified T at runtime | ✅ (via AOT) | ❌ (erased) | ❌ | ⚠️ (`inline fun` only — can't reify a class type parameter) | ✅ (monomorphic) | | `instanceof OriginalFqn` works | ✅ | ✅ (trivially: only one class exists at runtime) | n/a | n/a | n/a | | Real subtype edges between specializations | ⚠️ (common case works; some covariant upcasts are unschedulable or may not converge) | ❌ (erased) | n/a | n/a | n/a | | Generic type aliases | ❌ | ❌ | ✅ | ✅ | ✅ | | Wildcard / `*` (use-site existential) | ⚠️ partial (via marker) | n/a (erased) | ⚠️ via `any` (bivariant escape hatch — loses type discipline) | ✅ (`Box<*>`) | n/a | | Use-site variance | ❌ | ❌ | ❌ | ✅ | n/a | | Variadic generics | ❌ | ❌ | ✅ | ❌ | ⚠️ tuples | -| Generic enums / sum types | ❌ | ❌ | ✅ | ✅ | ✅ | +| Generic enums / sum types | ❌ | ❌ | ✅ (via discriminated unions; `enum` can't be generic) | ✅ (via `sealed` classes; `enum class` can't be generic) | ✅ | | Per-arg specialization | ❌ | ❌ (erasure) | ❌ | ❌ | ⚠️ nightly | | Associated types | ❌ | n/a | ❌ | ❌ | ✅ | | `T[]` array sugar | ✅ | ❌ | ✅ | ❌ | ❌ | From 21b711abe368cf7b4feff9141f20517f1b5ed73e Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 27 Jul 2026 21:14:09 +0000 Subject: [PATCH 19/46] docs(comparison): link the self-reintroducing-specialization caveat The "Real subtype edges" bullet linked the caveat write-up for the array-backed-collection sub-problem but not for the self-reintroducing list-map derivation, even though that one also has a dedicated section in caveats.md (with a workaround). Add the missing link so a reader who hits the convergence error finds the fix. The unschedulable-upcast sub-problem stays unlinked by design -- it has no caveats section, only the inline error code. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/guides/comparison.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/guides/comparison.md b/docs/guides/comparison.md index f1d0af8d..af5b0b11 100644 --- a/docs/guides/comparison.md +++ b/docs/guides/comparison.md @@ -162,7 +162,8 @@ reproduction and workaround) in [caveats](../caveats.md). strength — see above), but not universal: some covariant upcasts to an interface with an erased element-consuming method are **unschedulable** and fail loudly (`xphp.unschedulable_covariant_upcast`), a - self-reintroducing list-↔-map derivation can fail to converge, and a + self-reintroducing list-↔-map derivation can fail to converge + ([caveat](../caveats.md#self-reintroducing-specialization-list--map-derivations)), and a covariant `array`-backed collection trips the optional PHPStan pass at level 6+ ([caveat](../caveats.md#covariant-array-backed-collections-trip-the-xphp-check-phpstan-pass)). From 407890a817c41fc658507f7186994f4f20aacaec Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 29 Jul 2026 12:23:57 +0000 Subject: [PATCH 20/46] feat(inference): add pure type-argument unifier and inference driver Introduce TypeInference, the pure core that derives a generic call or instantiation's type arguments from its ordinary arguments' static types -- the structural inverse of Specializer::substituteTypeRef. It carries no pipeline state: the unifier walks a parameter type and a concrete argument type in lock-step binding each type-parameter leaf, subtype arguments are threaded up the supertype chain via TypeHierarchy::resolveInheritedArgs, and the driver pairs arguments to parameters (positional, named, variadic, spread- and first-class-callable-aware), unifies, and assembles the bindings into a declaration-ordered type-argument prefix. Inference resolves only to a complete, unambiguous, concrete tuple (or a concrete prefix whose omitted tail is entirely defaulted); a conflict, ambiguity, unknown argument type, or a hole yields null, so a later caller can leave the site bare and fall back to the explicit-turbofish path unchanged. Flow-dependent typing is delegated through the ExpressionTyper seam, keeping this logic outside the monomorphizer's anonymous NodeVisitor classes where it is directly unit- and mutation-testable. No wiring yet -- this class has no callers; the call and new sites adopt it in following commits. 44 unit tests; 100% MSI on the new code. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/ExpressionTyper.php | 29 + src/Transpiler/Monomorphize/TypeInference.php | 302 +++++++++ .../Monomorphize/TypeInferenceTest.php | 639 ++++++++++++++++++ 3 files changed, 970 insertions(+) create mode 100644 src/Transpiler/Monomorphize/ExpressionTyper.php create mode 100644 src/Transpiler/Monomorphize/TypeInference.php create mode 100644 test/Transpiler/Monomorphize/TypeInferenceTest.php diff --git a/src/Transpiler/Monomorphize/ExpressionTyper.php b/src/Transpiler/Monomorphize/ExpressionTyper.php new file mode 100644 index 00000000..80095a9d --- /dev/null +++ b/src/Transpiler/Monomorphize/ExpressionTyper.php @@ -0,0 +1,29 @@ +prop`, or a call return — which only the + * monomorphizer's receiver/scope tracker can answer, and only inside a method body. + * + * Returning null means "cannot determine a concrete type here"; the inference driver then leaves + * that argument's parameter unconstrained (which, if it was the only witness for a required type + * parameter, makes the whole inference fall back to today's explicit-turbofish requirement). An + * implementation must never invent a type it cannot prove — an unknown expression is null, never a + * guess. + */ +interface ExpressionTyper +{ + /** The concrete static type of `$expr`, or null when it cannot be determined. */ + public function typeOf(Expr $expr): ?TypeRef; +} diff --git a/src/Transpiler/Monomorphize/TypeInference.php b/src/Transpiler/Monomorphize/TypeInference.php new file mode 100644 index 00000000..50888761 --- /dev/null +++ b/src/Transpiler/Monomorphize/TypeInference.php @@ -0,0 +1,302 @@ +` turbofish becomes optional wherever the argument values determine it. + * + * The algorithm is the structural inverse of {@see Specializer::substituteTypeRef}: substitution + * takes a template type (`Box`) plus a binding (`T => int`) and produces a concrete type + * (`Box`); inference takes the template parameter type (`Box`) plus the concrete argument + * type (`Box`) and recovers the binding (`T => int`). {@see unify} walks the two trees in + * lock-step, binding each type-parameter leaf to the corresponding concrete sub-type; {@see infer} + * pairs each argument with its parameter, unifies, and assembles the bindings into a + * declaration-ordered type-argument list. + * + * This class is pure and context-free: everything flow-dependent (what type a `$var` or a call + * return actually has) is delegated to an injected {@see ExpressionTyper}. That keeps the load- + * bearing logic — conflict detection, subtype threading, the prefix/gap rules — in one small + * unit-testable place, deliberately outside the monomorphizer's anonymous NodeVisitor classes + * (Infection's blind spot). + * + * Soundness is by construction: inference only ever produces the exact concrete tuple an explicit + * turbofish would have carried, and downstream (bounds, variance edges, mangling, specialization) + * treats the two identically. Anything it cannot resolve to a complete, unambiguous, concrete tuple + * yields null, and the caller leaves the site bare — falling back to today's exact behaviour. + */ +final class TypeInference +{ + public function __construct(private readonly TypeHierarchy $hierarchy) + { + } + + /** + * Infer the type arguments for a generic callee from its call-site arguments. + * + * Returns the inferred type arguments in declaration order — a *complete* tuple, or a concrete + * prefix whose omitted tail is entirely defaulted (so {@see Registry::padArgsWithDefaults} + * fills it exactly as it would for a partial explicit turbofish). Returns null — meaning "leave + * the site bare, fall back to the explicit-turbofish path" — when inference is: + * + * - impossible: a required (non-defaulted) type parameter has no argument witness, or an + * argument's static type is unknown so its parameter stays unconstrained; + * - ambiguous: an argument is a subtype reaching the parameter's type through conflicting + * supertype paths; + * - conflicting: a type parameter used in two parameters is witnessed as two different types + * (`pair(T $a, T $b)` called `pair(1, 'x')`); + * - a "hole": an inferred parameter follows an un-inferred one (not a clean prefix). + * + * @param list $typeParams the callee's generic parameters, in declaration order + * @param list $params the callee's value parameters, from the template AST + * @param list $args the call-site arguments + * @return list|null + */ + public function infer(array $typeParams, array $params, array $args, ExpressionTyper $typer): ?array + { + // A non-generic callee (empty $typeParams) needs no guard here: assemblePrefix() returns + // null for an empty parameter list anyway. + $nameSet = []; + foreach ($typeParams as $param) { + // @infection-ignore-all TrueValue -- $nameSet is a set: membership is tested with + // isset() in paramTypeRef(), so the stored value is immaterial (same rationale as the + // on-path sentinel in TypeHierarchy::groundPaths). + $nameSet[$param->name] = true; + } + + /** @var array $bindings type-param name => inferred concrete type */ + $bindings = []; + foreach (self::pairArgsToParams($params, $args) as [$param, $value]) { + $paramType = self::paramTypeRef($param->type, $nameSet); + if ($paramType === null || !self::mentionsTypeParam($paramType)) { + // The parameter's declared type constrains no type parameter — it contributes + // nothing to inference (and, crucially, leaves all-defaults templates untouched). + continue; + } + $argType = $typer->typeOf($value); + if ($argType === null) { + // Unknown argument type: leave this parameter's type variables unconstrained. + continue; + } + if (!$this->unify($paramType, $argType, $bindings)) { + return null; + } + } + + return self::assemblePrefix($typeParams, $bindings); + } + + /** + * Bind the type-parameter leaves of `$paramType` from the concrete `$argType`, the inverse of + * {@see Specializer::substituteTypeRef}. Accumulates bindings by reference and returns whether + * the two types are unifiable. Failure modes: + * + * - a type-parameter leaf against a non-concrete argument (nothing concrete to bind); + * - a type parameter already bound to a different type (multi-occurrence conflict); + * - a parametric head the argument neither shares nor reaches as a supertype, or reaches + * ambiguously, or with mismatched arity. + * + * A plain (non-generic, non-type-param) parameter leaf imposes no constraint and unifies + * vacuously — inference derives type arguments; it does not re-check argument assignability + * (that is the compiler's separate job, run identically for inferred and explicit turbofishes). + * + * @param array $bindings + * @param-out array $bindings + */ + public function unify(TypeRef $paramType, TypeRef $argType, array &$bindings): bool + { + if ($paramType->isTypeParam) { + if (!$argType->isConcrete()) { + return false; + } + $existing = $bindings[$paramType->name] ?? null; + if ($existing !== null) { + return $existing->canonical() === $argType->canonical(); + } + $bindings[$paramType->name] = $argType; + return true; + } elseif (!$paramType->isGeneric()) { + // A plain concrete leaf imposes no constraint. Kept as an `elseif` deliberately: were + // it a separate `if`, dropping the type-param branch's `return true` above would fall + // through to this same `true` (an equivalent mutant); the chain routes that fall-through + // into the parametric block below instead, where a type-param head is rejected. + return true; + } + // A parametric head: view the argument as this head (directly, or threaded up its supertype + // chain when the argument is a subtype) and unify the type arguments pairwise. + $argArgs = $this->hierarchy->resolveInheritedArgs($argType->name, $argType->args, $paramType->name); + if ($argArgs === null || count($argArgs) !== count($paramType->args)) { + return false; + } + foreach ($paramType->args as $i => $sub) { + if (!$this->unify($sub, $argArgs[$i], $bindings)) { + return false; + } + } + return true; + } + + /** + * Convert a parameter's declared type AST node into a {@see TypeRef}, marking every leaf whose + * name is one of the callee's type parameters (`$typeParamNames`) as a type-param leaf. A + * generic parameter type (`Box`) reuses the type arguments the parser already resolved and + * attached (with their own leaves flagged), so nesting to any depth is handled by the parser's + * own resolution. Returns null for a shape inference does not model — a union, an intersection, + * or a missing type — so its parameter contributes no constraint. + * + * Public so the `new`-inference pass can reuse the exact same parameter→TypeRef mapping over a + * constructor's parameters. + * + * @param array $typeParamNames + */ + public static function paramTypeRef(?Node $type, array $typeParamNames): ?TypeRef + { + if ($type instanceof NullableType) { + $type = $type->type; + } + if ($type instanceof Identifier) { + // A scalar or keyword type (`int`, `string`, `array`, ...) — concrete, no type params. + return new TypeRef(strtolower($type->name), isScalar: true); + } + if ($type instanceof Name) { + $name = $type->toString(); + if (isset($typeParamNames[$name])) { + return new TypeRef($name, isTypeParam: true); + } + $args = $type->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + $resolved = $type->getAttribute(XphpSourceParser::ATTR_RESOLVED_FQN); + /** @var list $argRefs */ + $argRefs = is_array($args) ? $args : []; + return new TypeRef(is_string($resolved) ? $resolved : $name, $argRefs); + } + return null; + } + + /** + * Assemble the bindings into a declaration-ordered type-argument list, or null when they do not + * form a "concrete prefix + defaulted tail": every bound parameter must precede every unbound + * one (no hole), and every unbound parameter must be defaultable. An empty result (nothing + * inferred) is null too — the site stays bare. + * + * @param list $typeParams + * @param array $bindings + * @return list|null + */ + private static function assemblePrefix(array $typeParams, array $bindings): ?array + { + $prefix = []; + $sawUnbound = false; + foreach ($typeParams as $param) { + $bound = $bindings[$param->name] ?? null; + if ($bound !== null) { + if ($sawUnbound) { + return null; + } + $prefix[] = $bound; + continue; + } + if ($param->default === null) { + return null; + } + $sawUnbound = true; + } + return $prefix === [] ? null : $prefix; + } + + private static function mentionsTypeParam(TypeRef $ref): bool + { + if ($ref->isTypeParam) { + return true; + } + foreach ($ref->args as $arg) { + if (self::mentionsTypeParam($arg)) { + return true; + } + } + return false; + } + + /** + * Pair each call argument with the callee parameter it binds, mirroring PHP's own rules: + * a named argument binds by parameter name; a positional argument binds by position (a trailing + * variadic absorbs the overflow); a spread (`...$xs`) stops positional pairing, since it + * rebinds every following slot at runtime; and a first-class-callable placeholder carries no + * value and is skipped. Arguments with no matching parameter are dropped. + * + * @param list $params + * @param list $args + * @return list + */ + private static function pairArgsToParams(array $params, array $args): array + { + $pairs = []; + $position = 0; + $sawSpread = false; + foreach ($args as $arg) { + if (!$arg instanceof Arg) { + continue; + } + if ($arg->name instanceof Identifier) { + $param = self::paramByName($params, $arg->name->toString()); + if ($param !== null) { + $pairs[] = [$param, $arg->value]; + } + continue; + } + if ($sawSpread) { + continue; + } + if ($arg->unpack) { + $sawSpread = true; + continue; + } + $param = self::paramForPosition($params, $position); + if ($param !== null) { + $pairs[] = [$param, $arg->value]; + } + $position++; + } + return $pairs; + } + + /** + * The parameter a named argument binds, or null when no parameter has that name. + * + * @param list $params + */ + private static function paramByName(array $params, string $name): ?Param + { + foreach ($params as $param) { + if ($param->var instanceof Variable && $param->var->name === $name) { + return $param; + } + } + return null; + } + + /** + * The parameter a positional argument at `$index` binds: the parameter at that index, or a + * trailing variadic that absorbs everything past the fixed arity, or null when the call + * over-supplies a non-variadic list. + * + * @param list $params + */ + private static function paramForPosition(array $params, int $index): ?Param + { + if (isset($params[$index])) { + return $params[$index]; + } + $last = $params === [] ? null : $params[array_key_last($params)]; + return $last !== null && $last->variadic ? $last : null; + } +} diff --git a/test/Transpiler/Monomorphize/TypeInferenceTest.php b/test/Transpiler/Monomorphize/TypeInferenceTest.php new file mode 100644 index 00000000..f55117cf --- /dev/null +++ b/test/Transpiler/Monomorphize/TypeInferenceTest.php @@ -0,0 +1,639 @@ +(T $x) called with an int → [int] + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('x', new Name('T'))], + [self::arg('a')], + self::typer(['a' => self::int()]), + ); + self::assertSame('int', self::canonicals($result)); + } + + public function testMultiOccurrenceConsistentBindingSucceeds(): void + { + // pair(T $a, T $b) with (int, int) → [int] + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('a', new Name('T')), self::param('b', new Name('T'))], + [self::arg('x'), self::arg('y')], + self::typer(['x' => self::int(), 'y' => self::int()]), + ); + self::assertSame('int', self::canonicals($result)); + } + + public function testMultiOccurrenceConflictFallsBack(): void + { + // pair(T $a, T $b) with (int, string) → conflict → null + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('a', new Name('T')), self::param('b', new Name('T'))], + [self::arg('x'), self::arg('y')], + self::typer(['x' => self::int(), 'y' => self::string()]), + ); + self::assertNull($result); + } + + public function testInfersThroughMatchingParametricHead(): void + { + // unwrap(Box $b) with Box → [int] + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('b', self::generic(self::BOX, [self::typeParamRef('T')]))], + [self::arg('x')], + self::typer(['x' => self::box(self::int())]), + ); + self::assertSame('int', self::canonicals($result)); + } + + public function testInfersThroughNestedParametricHeads(): void + { + // f(Box> $b) with Box> → [int] + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('b', self::generic(self::BOX, [ + new TypeRef(self::BOX, [self::typeParamRef('T')]), + ]))], + [self::arg('x')], + self::typer(['x' => self::box(self::box(self::int()))]), + ); + self::assertSame('int', self::canonicals($result)); + } + + public function testInfersThroughSubtypeSupertypeChain(): void + { + // take(Collection $c) with an ArrayList argument → [int] + $result = $this->infer($this->arrayListImplementsCollection())->infer( + [new TypeParam('T')], + [self::param('c', self::generic(self::COLLECTION, [self::typeParamRef('T')]))], + [self::arg('x')], + self::typer(['x' => new TypeRef(self::ARRAY_LIST, [self::int()])]), + ); + self::assertSame('int', self::canonicals($result)); + } + + public function testAmbiguousSupertypePathFallsBack(): void + { + // A type that grounds Collection to two different args along two paths → null. + $hierarchy = new TypeHierarchy( + ['App\\Weird' => [self::COLLECTION]], + ['App\\Weird' => [ + new TypeRef(self::COLLECTION, [self::int()]), + new TypeRef(self::COLLECTION, [self::string()]), + ]], + ['App\\Weird' => [], self::COLLECTION => ['E']], + ); + $result = $this->infer($hierarchy)->infer( + [new TypeParam('T')], + [self::param('c', self::generic(self::COLLECTION, [self::typeParamRef('T')]))], + [self::arg('x')], + self::typer(['x' => new TypeRef('App\\Weird', [])]), + ); + self::assertNull($result); + } + + public function testUnrelatedParametricHeadFallsBack(): void + { + // Collection parameter, an argument whose type reaches no Collection supertype → null. + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('c', self::generic(self::COLLECTION, [self::typeParamRef('T')]))], + [self::arg('x')], + self::typer(['x' => new TypeRef('App\\Unrelated', [self::int()])]), + ); + self::assertNull($result); + } + + public function testArityMismatchOnParametricHeadFallsBack(): void + { + // Pair parameter head, a two-arg argument against a one-arg parameter shape → null. + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('p', self::generic('App\\Pair', [self::typeParamRef('T')]))], + [self::arg('x')], + self::typer(['x' => new TypeRef('App\\Pair', [self::int(), self::string()])]), + ); + self::assertNull($result); + } + + public function testDefaultedTrailingParamIsOmittedFromInferredPrefix(): void + { + // pair(A $a) with an int → [int]; B is left for padding to default. + $result = $this->infer()->infer( + [new TypeParam('A'), new TypeParam('B', null, self::typeParamRef('A'))], + [self::param('a', new Name('A'))], + [self::arg('x')], + self::typer(['x' => self::int()]), + ); + self::assertSame('int', self::canonicals($result)); + } + + public function testHoleBetweenBoundParamsFallsBack(): void + { + // two(B $b): only the second type param is witnessed → not a clean prefix → null. + $result = $this->infer()->infer( + [new TypeParam('A'), new TypeParam('B')], + [self::param('b', new Name('B'))], + [self::arg('x')], + self::typer(['x' => self::int()]), + ); + self::assertNull($result); + } + + public function testUnwitnessedRequiredParamFallsBack(): void + { + // two(A $a): B is required but has no argument witness → null. + $result = $this->infer()->infer( + [new TypeParam('A'), new TypeParam('B')], + [self::param('a', new Name('A'))], + [self::arg('x')], + self::typer(['x' => self::int()]), + ); + self::assertNull($result); + } + + public function testUnknownArgumentTypeLeavesParamUnconstrained(): void + { + // identity(T $x) with an argument the typer cannot type → null. + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('x', new Name('T'))], + [self::arg('a')], + self::typer([]), + ); + self::assertNull($result); + } + + public function testAbstractArgumentAgainstTypeParamFallsBack(): void + { + // A non-concrete argument type cannot bind a type parameter → null. + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('x', new Name('T'))], + [self::arg('a')], + self::typer(['a' => self::typeParamRef('U')]), + ); + self::assertNull($result); + } + + public function testUnionTypedParamIsSkipped(): void + { + // f(int|string $u, T $x): the union parameter is a shape inference does not model, so it + // is skipped (short-circuit) and T is still inferred from the second argument → [int]. + $result = $this->infer()->infer( + [new TypeParam('T')], + [ + self::param('u', new UnionType([new Identifier('int'), new Identifier('string')])), + self::param('x', new Name('T')), + ], + [self::arg('a'), self::arg('b')], + self::typer(['a' => self::int(), 'b' => self::int()]), + ); + self::assertSame('int', self::canonicals($result)); + } + + public function testEarlierUnknownArgumentDoesNotBlockLaterBinding(): void + { + // pair(T $a, T $b) with (unknown, int): the first argument's type is unknown, but the + // second still witnesses T → [int]. (Distinguishes "skip this pair" from "stop pairing".) + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('a', new Name('T')), self::param('b', new Name('T'))], + [self::arg('x'), self::arg('y')], + self::typer(['y' => self::int()]), + ); + self::assertSame('int', self::canonicals($result)); + } + + public function testConcreteGenericParamImposesNoConstraint(): void + { + // f(Box $fixed, T $x) with (an unrelated-typed arg, int): the concrete Box + // parameter mentions no type parameter, so it is skipped rather than unified against the + // unrelated argument (which would spuriously fail) → [int]. + $result = $this->infer()->infer( + [new TypeParam('T')], + [ + self::param('fixed', self::generic(self::BOX, [self::int()])), + self::param('x', new Name('T')), + ], + [self::arg('a'), self::arg('b')], + self::typer(['a' => new TypeRef('App\\Unrelated', [self::int()]), 'b' => self::int()]), + ); + self::assertSame('int', self::canonicals($result)); + } + + public function testHoleAfterDefaultedParamFallsBack(): void + { + // pair(B $b): only the second (defaulted) param is witnessed while the + // first is not — a hole that would misalign the prefix, so inference falls back → null. + $result = $this->infer()->infer( + [new TypeParam('A', null, self::int()), new TypeParam('B', null, self::int())], + [self::param('b', new Name('B'))], + [self::arg('x')], + self::typer(['x' => self::string()]), + ); + self::assertNull($result); + } + + public function testPlainConcreteParamImposesNoConstraint(): void + { + // f(int $n, T $x) with (int, string) → [string]; the int parameter constrains nothing. + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('n', new Identifier('int')), self::param('x', new Name('T'))], + [self::arg('a'), self::arg('b')], + self::typer(['a' => self::int(), 'b' => self::string()]), + ); + self::assertSame('string', self::canonicals($result)); + } + + public function testNonGenericCalleeInfersNothing(): void + { + self::assertNull($this->infer()->infer([], [], [], self::typer([]))); + } + + public function testAllDefaultsTemplateWithNoTypeParamParamsStaysBare(): void + { + // cache() — no parameter mentions T, so nothing is inferred; the site stays bare + // and the existing all-defaults path is untouched. + $result = $this->infer()->infer( + [new TypeParam('T', null, self::int())], + [], + [], + self::typer([]), + ); + self::assertNull($result); + } + + // ---- argument pairing --------------------------------------------------------------------- + + public function testNamedArgumentsBindByParameterName(): void + { + // kv(K $k, V $v) called v: string, k: int (out of order) → [int, string] + $result = $this->infer()->infer( + [new TypeParam('K'), new TypeParam('V')], + [self::param('k', new Name('K')), self::param('v', new Name('V'))], + [self::namedArg('v', 'sv'), self::namedArg('k', 'sk')], + self::typer(['sv' => self::string(), 'sk' => self::int()]), + ); + self::assertSame('int,string', self::canonicals($result)); + } + + public function testUnknownNamedArgumentIsDropped(): void + { + // A named argument matching no parameter is ignored; T stays unwitnessed → null. + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('x', new Name('T'))], + [self::namedArg('nope', 'a')], + self::typer(['a' => self::int()]), + ); + self::assertNull($result); + } + + public function testSpreadStopsPositionalPairing(): void + { + // f(A $a, B $b): a spread in first position leaves both params unwitnessed → null. + $spread = new Arg(new Variable('rest'), unpack: true); + $result = $this->infer()->infer( + [new TypeParam('A'), new TypeParam('B')], + [self::param('a', new Name('A')), self::param('b', new Name('B'))], + [$spread, self::arg('x')], + self::typer(['x' => self::int()]), + ); + self::assertNull($result); + } + + public function testVariadicParamAbsorbsOverflowConsistently(): void + { + // all(T ...$xs) with (int, int) → [int] + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::variadicParam('xs', new Name('T'))], + [self::arg('a'), self::arg('b')], + self::typer(['a' => self::int(), 'b' => self::int()]), + ); + self::assertSame('int', self::canonicals($result)); + } + + public function testVariadicParamConflictFallsBack(): void + { + // all(T ...$xs) with (int, string) → conflict via the shared variadic slot → null + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::variadicParam('xs', new Name('T'))], + [self::arg('a'), self::arg('b')], + self::typer(['a' => self::int(), 'b' => self::string()]), + ); + self::assertNull($result); + } + + public function testExcessPositionalArgWithoutVariadicIsDropped(): void + { + // one(T $a) called with a second positional arg: the overflow has no parameter and is + // dropped; inference still succeeds from the first argument → [int]. + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('a', new Name('T'))], + [self::arg('x'), self::arg('y')], + self::typer(['x' => self::int(), 'y' => self::string()]), + ); + self::assertSame('int', self::canonicals($result)); + } + + public function testFirstClassCallablePlaceholderIsSkipped(): void + { + // identity(...) — the placeholder carries no value, so nothing is inferred → null. + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('x', new Name('T'))], + [new Node\VariadicPlaceholder()], + self::typer([]), + ); + self::assertNull($result); + } + + // ---- unify(): direct micro-coverage ------------------------------------------------------- + + public function testUnifyBindsTypeParamLeaf(): void + { + $bindings = []; + self::assertTrue($this->infer()->unify(self::typeParamRef('T'), self::int(), $bindings)); + self::assertSame('int', $bindings['T']->canonical()); + } + + public function testUnifyRejectsNonConcreteArgument(): void + { + $bindings = []; + self::assertFalse( + $this->infer()->unify(self::typeParamRef('T'), self::typeParamRef('U'), $bindings), + ); + self::assertSame([], $bindings); + } + + public function testUnifyRejectsConflictingRebinding(): void + { + $bindings = ['T' => self::int()]; + self::assertFalse($this->infer()->unify(self::typeParamRef('T'), self::string(), $bindings)); + } + + public function testUnifyPlainConcreteLeafSucceedsWithoutBinding(): void + { + $bindings = []; + self::assertTrue($this->infer()->unify(self::int(), self::string(), $bindings)); + self::assertSame([], $bindings); + } + + public function testUnifyRejectsUnrelatedParametricHead(): void + { + // Box against Collection: the argument's head neither matches nor reaches Box. + $bindings = []; + self::assertFalse($this->infer()->unify( + new TypeRef(self::BOX, [self::typeParamRef('T')]), + new TypeRef(self::COLLECTION, [self::int()]), + $bindings, + )); + } + + public function testUnifyRejectsParametricHeadArityMismatch(): void + { + // Box (one arg) against Box (two): a direct-hit head with mismatched arity. + $bindings = []; + self::assertFalse($this->infer()->unify( + new TypeRef(self::BOX, [self::typeParamRef('T')]), + new TypeRef(self::BOX, [self::int(), self::string()]), + $bindings, + )); + } + + // ---- argument pairing: spread and placeholder branches ----------------------------------- + + public function testSpreadDisablesPositionalPairing(): void + { + // f(T $a) with (...$rest, positional int): the spread makes the following positional + // unsound to pair, so T is never witnessed → null. + $spread = new Arg(new Variable('rest'), unpack: true); + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('a', new Name('T'))], + [$spread, self::arg('x')], + self::typer(['x' => self::int()]), + ); + self::assertNull($result); + } + + public function testNamedArgumentsAfterSpreadStillBind(): void + { + // f(T $a, T $b) with (...$rest, a: int, b: int): a spread stops positional pairing but + // named arguments after it still bind by name → [int]. + $spread = new Arg(new Variable('rest'), unpack: true); + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('a', new Name('T')), self::param('b', new Name('T'))], + [$spread, self::namedArg('a', 'x'), self::namedArg('b', 'y')], + self::typer(['x' => self::int(), 'y' => self::int()]), + ); + self::assertSame('int', self::canonicals($result)); + } + + public function testPositionalAfterSpreadSkippedButLaterNamedBinds(): void + { + // f(T $a, T $b) with (...$rest, positional junk, a: int, b: int): the post-spread + // positional is skipped (not a hard stop), and the later named arguments still bind → [int]. + $spread = new Arg(new Variable('rest'), unpack: true); + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('a', new Name('T')), self::param('b', new Name('T'))], + [$spread, self::arg('junk'), self::namedArg('a', 'x'), self::namedArg('b', 'y')], + self::typer(['junk' => self::string(), 'x' => self::int(), 'y' => self::int()]), + ); + self::assertSame('int', self::canonicals($result)); + } + + public function testPlaceholderBeforeArgumentDoesNotStopPairing(): void + { + // A first-class-callable placeholder is skipped (not a hard stop): a following ordinary + // argument still pairs. (Defensive — valid PHP never mixes the two, but the pairing must + // not break if it sees this shape.) + $result = $this->infer()->infer( + [new TypeParam('T')], + [self::param('a', new Name('T'))], + [new Node\VariadicPlaceholder(), self::arg('x')], + self::typer(['x' => self::int()]), + ); + self::assertSame('int', self::canonicals($result)); + } + + // ---- paramTypeRef() ----------------------------------------------------------------------- + + public function testParamTypeRefMarksTypeParamLeaf(): void + { + $ref = TypeInference::paramTypeRef(new Name('T'), ['T' => true]); + self::assertNotNull($ref); + self::assertTrue($ref->isTypeParam); + self::assertSame('T', $ref->name); + } + + public function testParamTypeRefUnwrapsNullable(): void + { + $ref = TypeInference::paramTypeRef(new NullableType(new Name('T')), ['T' => true]); + self::assertNotNull($ref); + self::assertTrue($ref->isTypeParam); + } + + public function testParamTypeRefTreatsScalarAsConcrete(): void + { + $ref = TypeInference::paramTypeRef(new Identifier('INT'), ['T' => true]); + self::assertNotNull($ref); + self::assertFalse($ref->isTypeParam); + self::assertTrue($ref->isScalar); + self::assertSame('int', $ref->name); + } + + public function testParamTypeRefReadsGenericArgsAndResolvedFqn(): void + { + $ref = TypeInference::paramTypeRef(self::generic(self::BOX, [self::typeParamRef('T')]), ['T' => true]); + self::assertNotNull($ref); + self::assertFalse($ref->isTypeParam); + self::assertSame(self::BOX, $ref->name); + self::assertSame('App\\Box', $ref->canonical()); + } + + public function testParamTypeRefOnConcreteNameWithoutResolvedFqnFallsBackToSpelling(): void + { + $ref = TypeInference::paramTypeRef(new Name('Whatever'), ['T' => true]); + self::assertNotNull($ref); + self::assertSame('Whatever', $ref->name); + self::assertSame([], $ref->args); + } + + public function testParamTypeRefReturnsNullForUnmodeledShapes(): void + { + self::assertNull(TypeInference::paramTypeRef(null, ['T' => true])); + self::assertNull(TypeInference::paramTypeRef( + new UnionType([new Name('T'), new Identifier('int')]), + ['T' => true], + )); + } + + // ---- helpers ------------------------------------------------------------------------------ + + private function infer(?TypeHierarchy $hierarchy = null): TypeInference + { + return new TypeInference($hierarchy ?? new TypeHierarchy([])); + } + + private function arrayListImplementsCollection(): TypeHierarchy + { + return new TypeHierarchy( + [self::ARRAY_LIST => [self::COLLECTION]], + [self::ARRAY_LIST => [new TypeRef(self::COLLECTION, [self::typeParamRef('E')])]], + [self::ARRAY_LIST => ['E'], self::COLLECTION => ['E']], + ); + } + + /** @param list|null $result */ + private static function canonicals(?array $result): string + { + self::assertNotNull($result); + return implode(',', array_map(static fn (TypeRef $r): string => $r->canonical(), $result)); + } + + private static function param(string $var, Node $type): Param + { + return new Param(new Variable($var), null, $type); + } + + private static function variadicParam(string $var, Node $type): Param + { + return new Param(new Variable($var), null, $type, false, true); + } + + private static function generic(string $shortResolved, array $args): Name + { + $name = new Name('Short'); + $name->setAttribute(XphpSourceParser::ATTR_GENERIC_ARGS, $args); + $name->setAttribute(XphpSourceParser::ATTR_RESOLVED_FQN, $shortResolved); + return $name; + } + + private static function arg(string $var): Arg + { + return new Arg(new Variable($var)); + } + + private static function namedArg(string $paramName, string $var): Arg + { + return new Arg(new Variable($var), name: new Identifier($paramName)); + } + + /** @param array $map variable name → its static type */ + private static function typer(array $map): ExpressionTyper + { + return new class ($map) implements ExpressionTyper { + /** @param array $map */ + public function __construct(private readonly array $map) + { + } + + public function typeOf(Expr $expr): ?TypeRef + { + if ($expr instanceof Variable && is_string($expr->name)) { + return $this->map[$expr->name] ?? null; + } + return null; + } + }; + } + + private static function int(): TypeRef + { + return new TypeRef('int', isScalar: true); + } + + private static function string(): TypeRef + { + return new TypeRef('string', isScalar: true); + } + + private static function typeParamRef(string $name): TypeRef + { + return new TypeRef($name, isTypeParam: true); + } + + private static function box(TypeRef $inner): TypeRef + { + return new TypeRef(self::BOX, [$inner]); + } +} From f553b5a7a6f19d4c94fffa505eac6a0ec4f17092 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 29 Jul 2026 12:30:07 +0000 Subject: [PATCH 21/46] feat(inference): type literals and constructions for inference Add LiteralTyper, the context-free ExpressionTyper the inference driver uses for arguments whose static type is read directly off the expression: scalar literals (int/float/string/bool), array literals, and object construction (new X(...), carrying any turbofish the parser resolved onto the new). It returns null for anything flow-dependent -- a variable, a property, a call return -- and for a construction that is not fully concrete (an un-turbofished generic new, or an anonymous/dynamic class), never inventing a type it cannot read completely. An array literal is typed isScalar like the parser types an `array` turbofish argument, so an inferred tuple is byte-identical to the one an explicit :: would produce. Still no callers -- the call and new sites wire this in with the flow tracker in following commits. 13 unit tests; 100% MSI on the new code. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/LiteralTyper.php | 76 +++++++++++ .../Monomorphize/LiteralTyperTest.php | 128 ++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 src/Transpiler/Monomorphize/LiteralTyper.php create mode 100644 test/Transpiler/Monomorphize/LiteralTyperTest.php diff --git a/src/Transpiler/Monomorphize/LiteralTyper.php b/src/Transpiler/Monomorphize/LiteralTyper.php new file mode 100644 index 00000000..f25e77f1 --- /dev/null +++ b/src/Transpiler/Monomorphize/LiteralTyper.php @@ -0,0 +1,76 @@ +prop`, a call return — is out of scope here and + * yields null; the monomorphizer's own receiver/scope tracker answers those (and typically composes + * with this typer, delegating literal/`new` shapes to it). A `new` whose type is not fully concrete + * (an un-turbofished generic construction, or an anonymous/dynamic class) also yields null: this + * typer never invents a type it cannot read directly and completely. + */ +final class LiteralTyper implements ExpressionTyper +{ + public function typeOf(Expr $expr): ?TypeRef + { + if ($expr instanceof Int_) { + return new TypeRef('int', isScalar: true); + } + if ($expr instanceof Float_) { + return new TypeRef('float', isScalar: true); + } + if ($expr instanceof String_) { + return new TypeRef('string', isScalar: true); + } + if ($expr instanceof ConstFetch) { + $name = strtolower($expr->name->toString()); + return $name === 'true' || $name === 'false' + ? new TypeRef('bool', isScalar: true) + : null; + } + if ($expr instanceof Array_) { + return new TypeRef('array', isScalar: true); + } + if ($expr instanceof New_) { + return self::typeOfNew($expr); + } + return null; + } + + /** + * The constructed type of a `new X(...)` expression: the class's resolved FQN plus any + * turbofish type arguments the parser attached (`new Box::()` → `Box`). Null for a + * dynamic (`new $class()`) or anonymous class, and for a construction that is not fully concrete + * (`new Box::()` inside a template, or a bare generic `new Box(...)` awaiting its own + * inference) — an abstract or unresolved type is no basis for inferring another. + */ + private static function typeOfNew(New_ $new): ?TypeRef + { + if (!$new->class instanceof Name) { + return null; + } + $resolved = $new->class->getAttribute(XphpSourceParser::ATTR_RESOLVED_FQN); + $args = $new->class->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + /** @var list $argRefs */ + $argRefs = is_array($args) ? $args : []; + $ref = new TypeRef(is_string($resolved) ? $resolved : $new->class->toString(), $argRefs); + return $ref->isConcrete() ? $ref : null; + } +} diff --git a/test/Transpiler/Monomorphize/LiteralTyperTest.php b/test/Transpiler/Monomorphize/LiteralTyperTest.php new file mode 100644 index 00000000..169cee4c --- /dev/null +++ b/test/Transpiler/Monomorphize/LiteralTyperTest.php @@ -0,0 +1,128 @@ +typer = new LiteralTyper(); + } + + public function testIntLiteral(): void + { + $ref = $this->typer->typeOf(new Int_(5)); + self::assertNotNull($ref); + self::assertSame('int', $ref->name); + self::assertTrue($ref->isScalar); + } + + public function testFloatLiteral(): void + { + $ref = $this->typer->typeOf(new Float_(1.5)); + self::assertNotNull($ref); + self::assertSame('float', $ref->name); + self::assertTrue($ref->isScalar); + } + + public function testStringLiteral(): void + { + $ref = $this->typer->typeOf(new String_('x')); + self::assertNotNull($ref); + self::assertSame('string', $ref->name); + self::assertTrue($ref->isScalar); + } + + public function testTrueAndFalseAreBool(): void + { + foreach (['true', 'false', 'TRUE', 'False'] as $literal) { + $ref = $this->typer->typeOf(new ConstFetch(new Name($literal))); + self::assertNotNull($ref, $literal); + self::assertSame('bool', $ref->name); + self::assertTrue($ref->isScalar); + } + } + + public function testNullConstantIsNotTyped(): void + { + self::assertNull($this->typer->typeOf(new ConstFetch(new Name('null')))); + } + + public function testOtherConstantIsNotTyped(): void + { + self::assertNull($this->typer->typeOf(new ConstFetch(new Name('PHP_EOL')))); + } + + public function testArrayLiteral(): void + { + $ref = $this->typer->typeOf(new Array_([])); + self::assertNotNull($ref); + self::assertSame('array', $ref->name); + // isScalar mirrors the parser, which types an `array` turbofish arg as scalar — inference + // must produce the byte-identical TypeRef an explicit `::` would. + self::assertTrue($ref->isScalar); + } + + public function testNewNonGeneric(): void + { + $class = new Name('Plastic'); + $class->setAttribute(XphpSourceParser::ATTR_RESOLVED_FQN, 'App\\Plastic'); + $ref = $this->typer->typeOf(new New_($class)); + self::assertNotNull($ref); + self::assertSame('App\\Plastic', $ref->name); + self::assertSame([], $ref->args); + } + + public function testNewWithTurbofishArgs(): void + { + // new Box::() — the parser has attached the resolved turbofish args. + $class = new Name('Box'); + $class->setAttribute(XphpSourceParser::ATTR_RESOLVED_FQN, 'App\\Box'); + $class->setAttribute(XphpSourceParser::ATTR_GENERIC_ARGS, [new TypeRef('int', isScalar: true)]); + $ref = $this->typer->typeOf(new New_($class)); + self::assertNotNull($ref); + self::assertSame('App\\Box', $ref->canonical()); + } + + public function testNewWithoutResolvedFqnFallsBackToSpelling(): void + { + $ref = $this->typer->typeOf(new New_(new Name('Whatever'))); + self::assertNotNull($ref); + self::assertSame('Whatever', $ref->name); + } + + public function testNewGenericWithAbstractArgIsNotTyped(): void + { + // new Box::() inside a template: the turbofish is not concrete, so it is no basis for + // inference. + $class = new Name('Box'); + $class->setAttribute(XphpSourceParser::ATTR_RESOLVED_FQN, 'App\\Box'); + $class->setAttribute(XphpSourceParser::ATTR_GENERIC_ARGS, [new TypeRef('T', isTypeParam: true)]); + self::assertNull($this->typer->typeOf(new New_($class))); + } + + public function testNewDynamicClassIsNotTyped(): void + { + // new $class() — the class is an expression, not a name. + self::assertNull($this->typer->typeOf(new New_(new Variable('class')))); + } + + public function testUntypedExpressionYieldsNull(): void + { + self::assertNull($this->typer->typeOf(new Variable('x'))); + } +} From c174d9c298fd297b86e6ce86443c078f53af3ab3 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 29 Jul 2026 12:53:08 +0000 Subject: [PATCH 22/46] feat(monomorphize): infer type arguments for generic calls Make the turbofish optional on generic function, static-method, and instance-method calls: at each bare-call seam, before the missing-type- argument error, infer the type arguments from the call arguments and, on a complete concrete tuple, dispatch exactly as an explicit ::<> turbofish would. The monomorphizer's rewrite visitor now implements ExpressionTyper, answering argument types from LiteralTyper (literals, new) and its own receiver/scope tracking (class-typed parameters and locals, $this properties, class-returning calls); TypeInference turns those into the type-argument tuple. A miss leaves the site bare, so a call whose arguments don't determine the type parameter (e.g. one used only in the return type), a type conflict, or an unknown argument type still hits today's xphp.missing_type_argument, identically in check and compile. Because an inferred call is annotated to be indistinguishable from a turbofished one, everything downstream -- bounds, variance edges, mangling, specialization, check/compile parity -- is unchanged and cannot tell the two apart. First-class-callables are never inferred, and generic closure ($var) calls keep the explicit-turbofish requirement (deferred). The five enclosing-param bare-call tests that asserted the old "bare call always errors" behavior now use genuinely non-inferable shapes (the type parameter only in the return type), preserving what they guard -- that an unresolvable bare call still errors loudly rather than emitting a broken call. A new integration test compiles, executes, and check-verifies inference across all three call kinds and every supported argument shape. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/GenericMethodCompiler.php | 191 ++++++++++++-- src/Transpiler/Monomorphize/TypeInference.php | 12 +- .../EnclosingParamBoundIntegrationTest.php | 37 +-- .../GenericInferenceIntegrationTest.php | 237 ++++++++++++++++++ .../inferred_call_arguments/source/Lib.xphp | 62 +++++ .../source/Models.xphp | 9 + .../inferred_call_arguments/source/Use.xphp | 21 ++ .../verify/runtime.php | 35 +++ 8 files changed, 566 insertions(+), 38 deletions(-) create mode 100644 test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php create mode 100644 test/fixture/compile/inferred_call_arguments/source/Lib.xphp create mode 100644 test/fixture/compile/inferred_call_arguments/source/Models.xphp create mode 100644 test/fixture/compile/inferred_call_arguments/source/Use.xphp create mode 100644 test/fixture/compile/inferred_call_arguments/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 7d7e9da4..7bed07ca 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -540,7 +540,7 @@ private function rewriteCallSites( // @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($index, $alreadyGenerated, $hashLength, $hierarchy, $topLevelAppends, $diagnostics, $currentFile, $closureValidator) extends NodeVisitorAbstract { + $visitor = new class($index, $alreadyGenerated, $hashLength, $hierarchy, $topLevelAppends, $diagnostics, $currentFile, $closureValidator) extends NodeVisitorAbstract implements ExpressionTyper { private string $currentNamespace = ''; private ?Namespace_ $currentNamespaceNode = null; /** @var array alias => fqn */ @@ -754,8 +754,16 @@ public function __construct( private readonly ?ClosureConformanceValidator $closureValidator, ) { $this->nsContext = new NamespaceContext(); + $this->literalTyper = new LiteralTyper(); } + /** + * Types literals and `new` for {@see typeOf} — the context-free half of argument typing; + * the flow-dependent half (variables, `$this->prop`, call returns) is answered by this + * visitor's own receiver/scope resolvers. + */ + private readonly LiteralTyper $literalTyper; + /** * Reset the visitor's lexical state for one drained (detached) specialized * stmt. The stmt is traversed outside any Namespace_/Use_/ClassLike parent, @@ -1502,14 +1510,13 @@ private function rewriteStaticCall(StaticCall $node): ?Node 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 - // non-all-default generic is an error — not a silent skip that emits a call to the - // stripped method and fatals at runtime. - $args = []; + // Optional turbofish: infer the method's type arguments from the call arguments. + // A miss yields [] and falls through to padArgsWithDefaults, which pads an + // all-defaults generic and reports/throws `xphp.missing_type_argument` otherwise — + // never a silent skip that emits a call to the stripped method and fatals at runtime. + $args = $this->inferCallTypeArgs($params, $template->params, $node->args) ?? []; } - /** @var list $args — set as a list by XphpSourceParser::resolveAndAttach (or empty after the all-defaults branch above). */ + /** @var list $args — set as a list by XphpSourceParser::resolveAndAttach (or inferred/empty 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)) { @@ -1696,14 +1703,13 @@ private function rewriteInstanceMethodCall(MethodCall|NullsafeMethodCall $node): 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 - // non-all-default generic is an error — not a silent skip that emits a call to the - // stripped method and fatals at runtime. - $args = []; + // Optional turbofish: infer the method's type arguments from the call arguments. + // A miss yields [] and falls through to padArgsWithDefaults, which pads an + // all-defaults generic and reports/throws `xphp.missing_type_argument` otherwise — + // never a silent skip that emits a call to the stripped method and fatals at runtime. + $args = $this->inferCallTypeArgs($params, $template->params, $node->args) ?? []; } - /** @var list $args — set as a list by XphpSourceParser::resolveAndAttach (or empty after the all-defaults branch above). */ + /** @var list $args — set as a list by XphpSourceParser::resolveAndAttach (or inferred/empty above). */ $location = new SourceLocation($this->currentFile, $node->getStartLine()); $padded = Registry::padArgsWithDefaults($params, $args, $key, $this->diagnostics, $location); // Arity first: in `check` mode padArgsWithDefaults collects an arity diagnostic and @@ -2639,6 +2645,155 @@ private static function boundHasUngroundedLeaf(BoundExpr $bound): bool return false; } + /** + * Infer a bare generic call's type arguments from its ordinary arguments' static types, + * so the turbofish is optional wherever the arguments determine it. Returns the inferred + * concrete arguments (ready to dispatch exactly as an explicit turbofish would), or null + * to fall back to today's missing-turbofish handling. Inference is skipped without a type + * hierarchy (nothing to ground subtypes or run the identical bound checks against), + * matching how the seams below guard their bound checks. + * + * @param list $typeParams the callee's generic parameters + * @param array $valueParams the callee's value parameters (template AST) + * @param array $args the call-site arguments + * @return list|null + */ + private function inferCallTypeArgs(array $typeParams, array $valueParams, array $args): ?array + { + if ($this->hierarchy === null) { + return null; + } + return (new TypeInference($this->hierarchy))->infer($typeParams, $valueParams, $args, $this); + } + + /** + * The concrete static type of an argument expression, for {@see TypeInference}. Literals + * and `new` are delegated to the context-free {@see LiteralTyper}; a variable, `$this` + * property, or call return is answered from this visitor's own receiver/scope tracking. + * Anything not statically determinable — an untyped local, a scalar flow value, an + * abstract (still-templated) type — is null, so its parameter is left unconstrained. + */ + public function typeOf(Node\Expr $expr): ?TypeRef + { + $literal = $this->literalTyper->typeOf($expr); + if ($literal !== null) { + return $literal; + } + if ($expr instanceof Variable && is_string($expr->name)) { + $fqn = $this->currentScopeParamTypes[$expr->name] + ?? $this->currentScopeLocalTypes[$expr->name] + ?? null; + if ($fqn === null) { + return null; + } + $args = $this->currentScopeParamTypeArgs[$expr->name] + ?? $this->currentScopeLocalTypeArgs[$expr->name] + ?? []; + return self::concreteOrNull(new TypeRef($fqn, $args)); + } + if ($expr instanceof PropertyFetch + && $expr->var instanceof Variable + && $expr->var->name === 'this' + && $expr->name instanceof Identifier + ) { + return $this->typeOfThisProperty($expr->name->toString()); + } + if ($expr instanceof MethodCall || $expr instanceof NullsafeMethodCall || $expr instanceof StaticCall) { + $return = $this->resolveCallReturn($expr); + return $return === null ? null : self::concreteOrNull(new TypeRef($return[0], $return[1])); + } + return null; + } + + /** + * The declared type of `$this->$propName` as a concrete TypeRef, or null when the class, + * property, or its type cannot be determined (an unknown class, a promoted-constructor or + * union-typed property, or a type that is not yet concrete in this template). + */ + private function typeOfThisProperty(string $propName): ?TypeRef + { + if ($this->currentClassFqn === null) { + return null; + } + $owner = $this->index->classLike($this->currentClassFqn); + if ($owner === null) { + return null; + } + 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) { + return null; + } + $args = $type->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + /** @var list $argRefs */ + $argRefs = is_array($args) ? $args : []; + return self::concreteOrNull(new TypeRef($this->resolveClassName($type), $argRefs)); + } + } + return null; + } + + /** A type is a basis for inference only when fully concrete; an abstract one is null. */ + private static function concreteOrNull(TypeRef $ref): ?TypeRef + { + return $ref->isConcrete() ? $ref : null; + } + + /** + * Try to infer a bare free-function call's type arguments; on success, annotate the node + * as if the turbofish had been written — so {@see rewriteFuncCall} re-dispatches it down + * the identical explicit-turbofish path — and return true. Only free-function calls (a + * Name callee) are inferred; a bare generic *closure* call ($var) keeps the explicit- + * turbofish requirement (deferred). The inferred prefix is padded to full arity with the + * template's defaults so the annotation carries the exact tuple a turbofish would. + * + * @param list $typeParams + */ + private function tryInferFuncCall(FuncCall $node, array $typeParams): bool + { + if (!$node->name instanceof Name) { + return false; + } + $fqn = $this->resolveGenericFunctionFqn($node->name); + if ($fqn === null) { + return false; + } + $template = $this->index->functionTemplate($fqn); + if ($template === null) { + return false; + } + $inferred = $this->inferCallTypeArgs($typeParams, $template->params, $node->args); + if ($inferred === null) { + return false; + } + // Free-function dispatch requires an exact-arity tuple (it does not pad), so fill any + // defaulted tail here. Inference only ever leaves a defaultable tail unbound, so this + // never reports — it yields the same complete tuple an explicit turbofish would. + $padded = Registry::padArgsWithDefaults( + $typeParams, + $inferred, + $fqn, + $this->diagnostics, + new SourceLocation($this->currentFile, $node->getStartLine()), + ); + if (count($padded) !== count($typeParams) || !self::allConcrete($padded)) { + return false; + } + $node->setAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS, $padded); + $node->setAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN, $fqn); + return true; + } + private function rewriteFuncCall(FuncCall $node): ?Node { $args = $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); @@ -2653,6 +2808,12 @@ private function rewriteFuncCall(FuncCall $node): ?Node if (!$node->isFirstClassCallable()) { $bare = $this->resolveBareGenericCall($node); if ($bare !== null) { + // Optional turbofish: infer the type arguments from the call arguments + // and re-dispatch as if they had been written. Only when that fails is a + // bare generic call the missing-type-arguments error it is today. + if ($this->tryInferFuncCall($node, $bare[0])) { + return $this->rewriteFuncCall($node); + } $this->reportMissingTurbofishArguments( $bare[1], new SourceLocation($this->currentFile, $node->getStartLine()), diff --git a/src/Transpiler/Monomorphize/TypeInference.php b/src/Transpiler/Monomorphize/TypeInference.php index 50888761..78f8515c 100644 --- a/src/Transpiler/Monomorphize/TypeInference.php +++ b/src/Transpiler/Monomorphize/TypeInference.php @@ -58,8 +58,8 @@ public function __construct(private readonly TypeHierarchy $hierarchy) * - a "hole": an inferred parameter follows an un-inferred one (not a clean prefix). * * @param list $typeParams the callee's generic parameters, in declaration order - * @param list $params the callee's value parameters, from the template AST - * @param list $args the call-site arguments + * @param array $params the callee's value parameters, from the template AST + * @param array $args the call-site arguments * @return list|null */ public function infer(array $typeParams, array $params, array $args, ExpressionTyper $typer): ?array @@ -233,8 +233,8 @@ private static function mentionsTypeParam(TypeRef $ref): bool * rebinds every following slot at runtime; and a first-class-callable placeholder carries no * value and is skipped. Arguments with no matching parameter are dropped. * - * @param list $params - * @param list $args + * @param array $params + * @param array $args * @return list */ private static function pairArgsToParams(array $params, array $args): array @@ -272,7 +272,7 @@ private static function pairArgsToParams(array $params, array $args): array /** * The parameter a named argument binds, or null when no parameter has that name. * - * @param list $params + * @param array $params */ private static function paramByName(array $params, string $name): ?Param { @@ -289,7 +289,7 @@ private static function paramByName(array $params, string $name): ?Param * trailing variadic that absorbs everything past the fixed arity, or null when the call * over-supplies a non-variadic list. * - * @param list $params + * @param array $params */ private static function paramForPosition(array $params, int $index): ?Param { diff --git a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php index 8a4f2683..ad310ac8 100644 --- a/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php +++ b/test/Transpiler/Monomorphize/EnclosingParamBoundIntegrationTest.php @@ -1714,24 +1714,25 @@ 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_<…>`. + // A turbofish-less call whose arguments don't determine the type parameter — here `T` appears + // only in the return type — cannot be inferred, so 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", + 'Box.xphp' => "(int \$n): T { throw new \\RuntimeException('x'); } }\n", + 'Use.xphp' => "pick(1);\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. + // The same non-inferable 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", + 'Box.xphp' => "(int \$n): T { throw new \\RuntimeException('x'); } }\n", + 'Use.xphp' => "pick(1);\n", ]); $codes = array_map(static fn (Diagnostic $d): string => $d->code, $collector->all()); self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); @@ -1760,10 +1761,11 @@ public function testBareCallToANonGenericMethodIsUnaffected(): void public function testBareStaticMethodGenericCallIsReported(): void { - // The static path (`Box::pick('b')`) has the identical silent-skip branch — also reported. + // The static path (`Box::pick(1)`) has the identical silent-skip branch — a non-inferable + // bare call (T only in the return type) is also reported. $collector = $this->check([ - 'Box.xphp' => "(T \$x): T { return \$x; } }\n", - 'Use.xphp' => " "(int \$n): T { throw new \\RuntimeException('x'); } }\n", + 'Use.xphp' => " $d->code, $collector->all()); self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); @@ -1771,20 +1773,21 @@ public function testBareStaticMethodGenericCallIsReported(): void 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_<…>`. + // The free-function path skipped bare calls via a different early return; a non-inferable + // bare call (T only in the return type) 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' => " "(int \$n): T { throw new \\RuntimeException('x'); }\n", + 'Use.xphp' => "check([ - 'fns.xphp' => "(T \$x): T { return \$x; }\n", - 'Use.xphp' => " "(int \$n): T { throw new \\RuntimeException('x'); }\n", + 'Use.xphp' => " $d->code, $collector->all()); self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); diff --git a/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php b/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php new file mode 100644 index 00000000..475bbbcf --- /dev/null +++ b/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php @@ -0,0 +1,237 @@ +` turbofish would be. Covers free-function, static-method, and instance-method + * calls; that inference records the same instantiation an explicit turbofish records; that check and + * compile agree; and that a call whose arguments do NOT determine the type still falls back to the + * `xphp.missing_type_argument` error rather than silently emitting a broken call. + */ +final class GenericInferenceIntegrationTest extends TestCase +{ + private string $work; + + protected function setUp(): void + { + $this->work = sys_get_temp_dir() . '/xphp-inference-' . uniqid('', true); + mkdir($this->work, 0o755, true); + } + + protected function tearDown(): void + { + self::rrmdir($this->work); + } + + private const LIB = <<<'PHP' + (T $x): T { return $x; } + final class Factory { public static function make(T $x): T { return $x; } } + final class Bag { public function put(U $x): U { return $x; } } + PHP; + + #[RunInSeparateProcess] + public function testInferredCallArgumentsRunAtRuntime(): void + { + // The non-negotiable gate: execute the emitted output. Every call site is bare; that the + // program runs and returns the right values proves the turbofish-less calls inferred their + // type arguments and dispatched to real specializations. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/inferred_call_arguments/source', + 'inference', + ); + try { + $fixture->registerAutoload('App'); + $runtime = require __DIR__ . '/../../fixture/compile/inferred_call_arguments/verify/runtime.php'; + $runtime($fixture); + } finally { + $fixture->cleanup(); + } + } + + public function testFreeFunctionInfersFromLiteral(): void + { + $dist = $this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => "`), not left as a bare `identity`. + self::assertStringContainsString('identity_T_', self::read($dist, 'Use.php')); + } + + public function testStaticMethodInfersFromLiteral(): void + { + $dist = $this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => "compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => "put(9);\n", + ]); + self::assertStringContainsString('put_', self::read($dist, 'Use.php')); + } + + public function testInferenceProducesTheSameSpecializationAsATurbofish(): void + { + // The inferred call and the explicit-turbofish call must dispatch to the byte-identical + // mangled specialization — inference just writes the turbofish for you. + $inferred = self::read($this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => "compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => "(5);\n", + ]), 'Use.php'); + + self::assertSame(1, preg_match('/identity_T_\w+/', $inferred, $inferredMatch)); + self::assertSame(1, preg_match('/identity_T_\w+/', $explicit, $explicitMatch)); + self::assertSame($explicitMatch[0], $inferredMatch[0]); + } + + public function testCheckAcceptsAnInferableCall(): void + { + $collector = $this->check([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => "all(), 'an inferable bare call must not be flagged'); + } + + public function testExplicitTurbofishStillCompiles(): void + { + // No regression: an explicit turbofish is unchanged by inference. + $dist = $this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => "(5);\n", + ]); + self::assertStringContainsString('identity_T_', self::read($dist, 'Use.php')); + } + + public function testFirstClassCallableIsNotInferred(): void + { + // `identity(...)` creates a Closure; it must not be inferred or flagged. + $collector = $this->check([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => "all(), 'a first-class-callable must not be inferred or flagged'); + } + + public function testConflictingArgumentsFallBackToErrorInCheck(): void + { + // pair(T $a, T $b) called (int, string): T is witnessed as two types → no inference → + // today's missing-type-argument error. + $collector = $this->check([ + 'Lib.xphp' => "(T \$a, T \$b): T { return \$a; }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testConflictingArgumentsFailCompile(): void + { + // Parity: the same conflict throws in compile mode. + $this->expectException(RuntimeException::class); + $this->compile([ + 'Lib.xphp' => "(T \$a, T \$b): T { return \$a; }\n", + 'Use.xphp' => " $files */ + private function compile(array $files): string + { + $src = $this->writeSources($files); + $dist = $src . '/dist'; + $this->newCompiler()->compile($this->sourcesIn($src), $src, $dist, $src . '/.xphp-cache'); + return $dist; + } + + /** @param array $files */ + private function check(array $files): DiagnosticCollector + { + $src = $this->writeSources($files); + return $this->newCompiler()->check($this->sourcesIn($src)); + } + + /** @param array $files */ + private function writeSources(array $files): string + { + $src = $this->work . '/' . uniqid('src', true); + mkdir($src, 0o755, true); + foreach ($files as $name => $contents) { + file_put_contents($src . '/' . $name, $contents); + } + return $src; + } + + private function sourcesIn(string $src): \XPHP\FileSystem\FilepathArray + { + return (new NativeFileFinder())->find($src) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + } + + private function newCompiler(): Compiler + { + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + return new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser((new ParserFactory())->createForHostVersion()), + 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/inferred_call_arguments/source/Lib.xphp b/test/fixture/compile/inferred_call_arguments/source/Lib.xphp new file mode 100644 index 00000000..466573a3 --- /dev/null +++ b/test/fixture/compile/inferred_call_arguments/source/Lib.xphp @@ -0,0 +1,62 @@ +(T $x): T +{ + return $x; +} + +function wrap(T $x): T +{ + return $x; +} + +final class Box +{ + public function __construct(private T $value) + { + } + + public function get(): T + { + return $this->value; + } + + public function dup(U $x): U + { + return $x; + } +} + +final class Factory +{ + public static function make(T $x): T + { + return $x; + } +} + +final class Consumer +{ + private Plastic $p; + + public function __construct() + { + $this->p = new Plastic(); + } + + // Argument typed from a class-typed property (`$this->p`). + public function viaProp(): Plastic + { + return wrap($this->p); + } + + // Argument typed from a class-typed parameter (`$q`). + public function viaParam(Plastic $q): Plastic + { + return wrap($q); + } +} diff --git a/test/fixture/compile/inferred_call_arguments/source/Models.xphp b/test/fixture/compile/inferred_call_arguments/source/Models.xphp new file mode 100644 index 00000000..b8b60b59 --- /dev/null +++ b/test/fixture/compile/inferred_call_arguments/source/Models.xphp @@ -0,0 +1,9 @@ +(7); +$dupped = $box->dup(9); + +// Free-function inference from a class-typed property and a class-typed parameter. +$consumer = new Consumer(); +$viaProp = $consumer->viaProp(); +$viaParam = $consumer->viaParam(new Plastic()); diff --git a/test/fixture/compile/inferred_call_arguments/verify/runtime.php b/test/fixture/compile/inferred_call_arguments/verify/runtime.php new file mode 100644 index 00000000..ff5a3544 --- /dev/null +++ b/test/fixture/compile/inferred_call_arguments/verify/runtime.php @@ -0,0 +1,35 @@ +targetDir . '/Models.php'; + require $fixture->targetDir . '/Lib.php'; + require $fixture->targetDir . '/Use.php'; + + Assert::assertSame(5, $intId, 'identity(5) inferred T=int and returned the value'); + Assert::assertInstanceOf('App\\Inference\\Plastic', $objId, 'identity(new Plastic()) inferred T=Plastic'); + Assert::assertSame('hi', $made, 'Factory::make(\'hi\') inferred T=string'); + Assert::assertSame(9, $dupped, '$box->dup(9) inferred U=int'); + Assert::assertInstanceOf('App\\Inference\\Plastic', $viaProp, 'wrap($this->p) inferred T=Plastic from the property type'); + Assert::assertInstanceOf('App\\Inference\\Plastic', $viaParam, 'wrap($q) inferred T=Plastic from the parameter type'); +}; From d88ac686db08a45dc7c57677baf2521687fbf641 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 29 Jul 2026 13:48:45 +0000 Subject: [PATCH 23/46] feat(monomorphize): infer type arguments for bare `new` Make the turbofish optional on generic class instantiation. A new front-end pass (NewInferencePass) infers a bare `new Box($x)`'s type arguments from its constructor arguments and annotates the node exactly as an explicit turbofish (or the all-defaults synthesis) would -- so the instantiation collector and, in compile, the call-site rewriter treat it identically. A `new` it can't resolve to a complete concrete tuple is left bare, falling back to today's all-defaults synthesis or missing-type- argument error. The pass runs after collectDefinitions (it needs the template registry) and before collectInstantiations, at the same position in both check and compile, so the two modes infer identically. In compile, collectDefinitions and the pass now run before the method compiler, so the pass sees the original ASTs -- not the stripped/appended ones the method compiler produces (which check never sees). The method compiler still runs before collectInstantiations, so its appended specializations are collected as before. Argument typing is conservative and sound: literals and `new` (via LiteralTyper), `$this` properties (from the declared type), and plain parameters -- but only a concretely-typed parameter that is never reassigned, so a rebound `$x` can't be typed from its stale declaration. A local, a reassigned parameter, a union type, or a type-parameter-typed argument yields no inference and the `new` falls back. The two bare-new check fixtures now use a genuinely non-inferable template (the type parameter only in a method return), preserving what they guard. Integration tests compile, execute, and check-verify `new` inference across literal/`new`/property/parameter arguments; NewInferencePass is at 100% MSI. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/Compiler.php | 23 +- .../Monomorphize/NewInferencePass.php | 324 ++++++++++++++++++ .../GenericInferenceIntegrationTest.php | 254 ++++++++++++++ .../bare_new_missing_arg/source/Box.xphp | 4 +- .../bare_new_missing_arg/source/Use.xphp | 8 +- .../source/Box.xphp | 4 +- .../source/Use.xphp | 8 +- .../inferred_new_arguments/source/Lib.xphp | 39 +++ .../inferred_new_arguments/source/Models.xphp | 9 + .../inferred_new_arguments/source/Use.xphp | 16 + .../inferred_new_arguments/verify/runtime.php | 30 ++ 11 files changed, 705 insertions(+), 14 deletions(-) create mode 100644 src/Transpiler/Monomorphize/NewInferencePass.php create mode 100644 test/fixture/compile/inferred_new_arguments/source/Lib.xphp create mode 100644 test/fixture/compile/inferred_new_arguments/source/Models.xphp create mode 100644 test/fixture/compile/inferred_new_arguments/source/Use.xphp create mode 100644 test/fixture/compile/inferred_new_arguments/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 1ea37ce7..23e8e60f 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -86,17 +86,28 @@ public function compile( // AST in compile-mode, so this must precede it). UndeclaredTypeParameterValidator::assertMethodLevel($astPerFile, $hierarchy); - $methodCompiler = new GenericMethodCompiler($this->hashLength, $hierarchy); - $methodCompiler->process($astPerFile); - // Phase 1b.i: collect class definitions across every source file. Splitting // definitions ahead of instantiations gives bare-`new Foo;` synthesis (added // in 1b.ii) a complete template registry so it can recognize Foo as an - // all-defaulted template regardless of the file-walk order. + // all-defaulted template regardless of the file-walk order. Collected BEFORE the + // method compiler runs so the type-argument inference pass below has the full + // template registry AND sees the original (un-stripped, un-appended) user ASTs — + // the same shape `check()` runs it against, keeping the two modes in parity. foreach ($astPerFile as $filepath => $ast) { $collector->collectDefinitions($ast, $filepath); } + // Optional turbofish on `new`: infer a bare `new Box($x)`'s type arguments from its + // constructor arguments and annotate it, so the instantiation collector + call-site + // rewriter treat it as an explicit turbofish. A `new` it can't resolve is left bare + // (all-defaults synthesis / missing-type-argument error). Runs before `process` so it + // never sees appended specializations (which `check` can't), and before + // collectInstantiations so the annotation is picked up. + (new NewInferencePass($registry, $hierarchy))->run($astPerFile); + + $methodCompiler = new GenericMethodCompiler($this->hashLength, $hierarchy); + $methodCompiler->process($astPerFile); + // Phase 1b.ii: validate defaults-against-bounds at the source level (so a // bad declaration like `class Box` fails BEFORE any // padded instantiation is recorded), then collect instantiations -- including @@ -501,6 +512,10 @@ public function check(FilepathArray $sources): DiagnosticCollector foreach ($astPerFile as $filepath => $ast) { $collector->collectDefinitions($ast, $filepath); } + // Optional turbofish on `new` (see compile()): infer bare `new` type arguments before + // instantiations are collected. Same pipeline position as compile — after definitions, + // before collectInstantiations — so check and compile infer identically. + (new NewInferencePass($registry, $hierarchy))->run($astPerFile); $registry->validateVariancePositions(); $registry->validateUndeclaredTypeParameters(); UndeclaredTypeParameterValidator::assertMethodLevel($astPerFile, $hierarchy, $diagnostics); diff --git a/src/Transpiler/Monomorphize/NewInferencePass.php b/src/Transpiler/Monomorphize/NewInferencePass.php new file mode 100644 index 00000000..8615d34a --- /dev/null +++ b/src/Transpiler/Monomorphize/NewInferencePass.php @@ -0,0 +1,324 @@ + the enclosing class stack, for `$this`-property typing */ + private array $classStack = []; + /** @var list> per-function scope: variable name => trustworthy concrete type */ + private array $scopes = []; + + public function __construct( + private readonly Registry $registry, + TypeHierarchy $hierarchy, + ) { + $this->ctx = new NamespaceContext(); + $this->literalTyper = new LiteralTyper(); + $this->inference = new TypeInference($hierarchy); + } + + /** @param array> $astPerFile */ + public function run(array $astPerFile): void + { + foreach ($astPerFile as $ast) { + $this->ctx = new NamespaceContext(); + $this->classStack = []; + $this->scopes = []; + $traverser = new NodeTraverser(); + $traverser->addVisitor($this); + $traverser->traverse($ast); + } + } + + public function enterNode(Node $node): null + { + if ($node instanceof Namespace_) { + $this->ctx->enterNamespace($node->name?->toString()); + } + if ($node instanceof Use_) { + $this->ctx->indexUse($node); + } elseif ($node instanceof GroupUse) { + $this->ctx->indexGroupUse($node); + } + if ($node instanceof ClassLike) { + $this->classStack[] = $node; + } + if ($node instanceof FunctionLike) { + $this->scopes[] = $this->scopeForFunction($node); + } + if ($node instanceof New_ + && $node->class instanceof Name + && $node->class->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS) === null + ) { + $this->tryInferNew($node->class, $node); + } + return null; + } + + public function leaveNode(Node $node): null + { + if ($node instanceof FunctionLike) { + array_pop($this->scopes); + } + if ($node instanceof ClassLike) { + array_pop($this->classStack); + } + return null; + } + + /** + * Infer and attach the type arguments of a bare `new`, or leave it untouched. The class must + * resolve to a generic template; a non-generic or unknown class is left for PHP / the bare-new + * synthesis to handle. + */ + private function tryInferNew(Name $class, New_ $node): void + { + $fqn = $this->ctx->resolveName($class); + $definition = $this->registry->definition($fqn); + if ($definition === null || $definition->typeParams === []) { + return; + } + $inferred = $this->inference->infer( + $definition->typeParams, + self::constructorParams($definition->templateAst), + $node->args, + $this, + ); + if ($inferred !== null) { + // Mirror synthesizeBareNewIfAllDefaults / an explicit turbofish: the collector records + // the instantiation off these two attributes, padding any defaulted tail itself. + $class->setAttribute(XphpSourceParser::ATTR_GENERIC_ARGS, $inferred); + $class->setAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN, $fqn); + } + } + + public function typeOf(Node\Expr $expr): ?TypeRef + { + $literal = $this->literalTyper->typeOf($expr); + if ($literal !== null) { + return $literal; + } + if ($expr instanceof Variable && is_string($expr->name)) { + $scope = $this->scopes === [] ? [] : $this->scopes[array_key_last($this->scopes)]; + return $scope[$expr->name] ?? null; + } + $propName = self::thisPropertyName($expr); + if ($propName !== null) { + return $this->typeOfThisProperty($propName); + } + return null; + } + + /** + * The property name of a `$this->prop` fetch, or null for any other expression. A defensive + * AST-shape guard: the `&&` chain narrows to exactly a plain `$this->name` fetch, excluding a + * `$other->prop`, a `$this->expr->prop`, a `$this->$dynamic`, or a method call. + */ + private static function thisPropertyName(Node\Expr $expr): ?string + { + // @infection-ignore-all -- each `&&` guards a distinct AST shape; flipping one to `||` + // either needs a receiver/name form that never appears as a plain property-fetch argument + // (a `$this->a->b` or variable-variable) or is observationally identical here. + if ($expr instanceof PropertyFetch + && $expr->var instanceof Variable + && $expr->var->name === 'this' + && $expr->name instanceof Identifier + ) { + return $expr->name->toString(); + } + return null; + } + + /** + * Build a function's variable-type scope: every parameter with a concrete declared type that is + * never reassigned in the body. A reassigned parameter is dropped — after `$x = …` it no longer + * holds its declared type, so inferring from that type would be unsound. + * + * @return array + */ + private function scopeForFunction(FunctionLike $fn): array + { + $typeParamNames = $this->enclosingTypeParamNames($fn); + $reassigned = self::reassignedNames($fn); + $scope = []; + foreach ($fn->getParams() as $param) { + // @infection-ignore-all LogicalOr -- defensive: a real parameter always has a Variable + // var with a string name (Error vars / expression names only arise on parse failures + // that never reach this pass), so both operands are always false and || vs && is + // observationally identical. + if (!$param->var instanceof Variable || !is_string($param->var->name)) { + continue; + } + $name = $param->var->name; + if (isset($reassigned[$name])) { + continue; + } + $type = TypeInference::paramTypeRef($param->type, $typeParamNames); + if ($type !== null && $type->isConcrete()) { + $scope[$name] = $type; + } + } + return $scope; + } + + /** The declared type of `$this->$propName`, or null when it is not a determinable concrete type. */ + private function typeOfThisProperty(string $propName): ?TypeRef + { + if ($this->classStack === []) { + return null; + } + $class = $this->classStack[array_key_last($this->classStack)]; + $typeParamNames = self::typeParamNamesOf($class); + foreach ($class->stmts as $stmt) { + if (!$stmt instanceof Property) { + continue; + } + foreach ($stmt->props as $prop) { + if ($prop->name->toString() !== $propName) { + continue; + } + $type = TypeInference::paramTypeRef($stmt->type, $typeParamNames); + return $type !== null && $type->isConcrete() ? $type : null; + } + } + return null; + } + + /** + * The type-parameter names in scope for a function: its enclosing class's parameters plus its + * own method/function-level generic parameters. A parameter or property typed by one of these + * is abstract here, so it never becomes a (spuriously concrete) inference source. + * + * @return array + */ + private function enclosingTypeParamNames(FunctionLike $fn): array + { + $names = $this->classStack === [] + ? [] + : self::typeParamNamesOf($this->classStack[array_key_last($this->classStack)]); + $methodParams = $fn->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + if (is_array($methodParams)) { + /** @var list $methodParams */ + foreach ($methodParams as $param) { + // @infection-ignore-all TrueValue -- $names is a set; membership is tested with + // isset() in paramTypeRef, so the stored value is immaterial. + $names[$param->name] = true; + } + } + return $names; + } + + /** @return array */ + private static function typeParamNamesOf(ClassLike $class): array + { + $params = $class->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + $names = []; + if (is_array($params)) { + /** @var list $params */ + foreach ($params as $param) { + $names[$param->name] = true; + } + } + return $names; + } + + /** + * The variable names assigned anywhere in a function body — the conservative "do not trust" + * set. Covers `=`, `=&`, compound-assign, and inc/dec; nested closures are included too, so a + * parameter a closure mutates by reference is (safely) not trusted. + * + * @return array + */ + private static function reassignedNames(FunctionLike $fn): array + { + $stmts = $fn->getStmts(); + // @infection-ignore-all ReturnRemoval -- a bodiless method (interface/abstract) has null + // stmts; the guard is for the type checker. Removing it is equivalent at runtime because + // NodeFinder::find(null) returns [] anyway (verified), yielding the same empty result. + if ($stmts === null) { + return []; + } + $targets = (new NodeFinder())->find($stmts, static fn (Node $n): bool => + $n instanceof Assign || $n instanceof AssignRef || $n instanceof AssignOp + || $n instanceof PreInc || $n instanceof PostInc + || $n instanceof PreDec || $n instanceof PostDec); + $names = []; + foreach ($targets as $target) { + /** @var Assign|AssignRef|AssignOp|PreInc|PostInc|PreDec|PostDec $target */ + $var = $target->var; + if ($var instanceof Variable && is_string($var->name)) { + // @infection-ignore-all TrueValue -- $names is a set; membership is tested with + // isset() in scopeForFunction, so the stored value is immaterial. + $names[$var->name] = true; + } + } + return $names; + } + + /** + * The parameters of a template's `__construct`, or an empty list when it declares none — the + * shape inference unifies the constructor arguments against. + * + * @return array + */ + private static function constructorParams(ClassLike $template): array + { + foreach ($template->stmts as $stmt) { + if ($stmt instanceof ClassMethod && strtolower($stmt->name->toString()) === '__construct') { + return $stmt->params; + } + } + return []; + } +} diff --git a/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php b/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php index 475bbbcf..ceb41aa9 100644 --- a/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php @@ -165,6 +165,260 @@ public function testConflictingArgumentsFailCompile(): void ]); } + // --- `new` inference ----------------------------------------------------------------------- + + private const BOX = <<<'PHP' + { public function __construct(private T $value) {} public function get(): T { return $this->value; } } + PHP; + + #[RunInSeparateProcess] + public function testInferredNewArgumentsRunAtRuntime(): void + { + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/inferred_new_arguments/source', + 'new-inference', + ); + try { + $fixture->registerAutoload('App'); + $runtime = require __DIR__ . '/../../fixture/compile/inferred_new_arguments/verify/runtime.php'; + $runtime($fixture); + } finally { + $fixture->cleanup(); + } + } + + public function testBareNewInfersFromLiteral(): void + { + $dist = $this->compile([ + 'Box.xphp' => self::BOX, + 'Use.xphp' => "check([ + 'Box.xphp' => self::BOX, + 'Use.xphp' => "all(), 'a `new` whose argument determines T must not be flagged'); + } + + public function testNonInferableBareNewStillErrorsInCheck(): void + { + // T appears only in a method return, so `new Box()` cannot infer it → still an error. + $collector = $this->check([ + 'Box.xphp' => " { public function get(): ?T { return null; } }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testNonInferableBareNewThrowsInCompile(): void + { + // Parity with check. + $this->expectException(RuntimeException::class); + $this->compile([ + 'Box.xphp' => " { public function get(): ?T { return null; } }\n", + 'Use.xphp' => " from the stale declaration (the value is now an int). + // It falls back to requiring an explicit turbofish instead of emitting an unsound type. + $collector = $this->check([ + 'Box.xphp' => self::BOX, + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testAllDefaultsBareNewStillSynthesizes(): void + { + // No regression: a bare `new` of an all-defaults template still pads from defaults; the + // inference pass leaves it bare (nothing to infer) and the synthesis path handles it. + $dist = $this->compile([ + 'Cache.xphp' => " { public function size(): int { return 0; } }\n", + 'Use.xphp' => "compile([ + 'Box.xphp' => " { public function __construct(private T \$v) {} }\n", + 'Use.xphp' => "check([ + 'Box.xphp' => self::BOX, + 'Use.xphp' => "all()); + } + + public function testVariableVariableArgumentFallsBack(): void + { + // `new Box($$name)` — the argument is a variable-variable (its name is an expression, not a + // string), so it can't be typed; inference falls back rather than mishandling the name. + $collector = $this->check([ + 'Box.xphp' => self::BOX, + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testNewInfersFromPropertyDeclaredAfterAMethod(): void + { + // The property scan must skip non-property statements (a method here) and keep looking, + // not stop at the first one. + $collector = $this->check([ + 'Box.xphp' => " { public function __construct(private T \$v) {} }\nfinal class H { public function boot(): void {} private Plastic \$p; public function f(): void { \$b = new Box(\$this->p); } }\n", + 'Use.xphp' => "all(), 'new Box($this->p) must infer even when p follows a method'); + } + + public function testNewFromUnionTypedPropertyFallsBack(): void + { + // A union-typed property is a shape paramTypeRef does not model (null), so the `new` falls + // back rather than dereferencing a null type. + $collector = $this->check([ + 'Box.xphp' => " { public function __construct(private T \$v) {} }\nfinal class H { private int|string \$u = 1; public function f(): void { \$b = new Box(\$this->u); } }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testMethodGenericParameterIsNotTrustedForNewInference(): void + { + // Inside a generic method, a parameter typed by the METHOD type parameter is abstract, so + // `new Box($x)` must not infer from it (that would fabricate a bogus class argument). + $collector = $this->check([ + 'Box.xphp' => " { public function __construct(private T \$v) {} }\nfinal class M { public function make(U \$x): void { \$b = new Box(\$x); } }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testArrowFunctionPresentDoesNotBreakInference(): void + { + // An arrow function has no statement body (getStmts() is null); the reassignment scan must + // tolerate that, and a bare `new Box(5)` alongside it still infers. + $dist = $this->compile([ + 'Box.xphp' => self::BOX, + 'Use.xphp' => " \$x;\n\$b = new Box(5);\n", + ]); + self::assertStringContainsString('Generated\\App\\Box\\T_', self::read($dist, 'Use.php')); + } + + public function testEveryReassignmentFormDropsTheParameter(): void + { + // Each reassignment form — `=`, `+=`, `=&`, `++x`, `x++`, `--x`, `x--` — must mark the + // parameter untrusted, so every `new Box($x)` falls back: one missing-type-argument per + // site. Pins the whole reassignment-detection predicate (and that ALL names are dropped, + // not just the first). + $body = '$a = 5; $b += 1; $ref = 1; $c =& $ref; ++$d; $e++; --$f; $g--; ' + . 'new Box($a); new Box($b); new Box($c); new Box($d); new Box($e); new Box($f); new Box($g);'; + $collector = $this->check([ + 'Box.xphp' => self::BOX, + 'Use.xphp' => " $d->code, $collector->all()); + self::assertSame(array_fill(0, 7, Registry::CODE_MISSING_TYPE_ARGUMENT), $codes); + } + + public function testReassignedParameterDoesNotBlockLaterTrustedParameter(): void + { + // The first parameter is reassigned (skipped), but the scan must CONTINUE to the second, + // trustworthy parameter — not stop at the first. + $dist = $this->compile([ + 'Box.xphp' => self::BOX, + 'Use.xphp' => "check([ + 'Box.xphp' => " { public function __construct(private T \$v) {} }\nfinal class C { public function f(E \$e): void { \$b = new Box(\$e); } }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testBodilessMethodDoesNotBreakTheReassignmentScan(): void + { + // An interface method has no body (getStmts() is null); the reassignment scan must tolerate + // that. A bare `new Box(5)` in the same program still infers. + $dist = $this->compile([ + 'Box.xphp' => " { public function __construct(private T \$v) {} }\n", + 'Use.xphp' => "check([ + 'Box.xphp' => self::BOX, + 'Use.xphp' => "all(), 'both new Box(...) infer from their trusted parameters'); + } + + public function testMultipleClassTypeParametersAreAllRecognized(): void + { + // A class with two type parameters: BOTH must be recognized as abstract (the name set isn't + // truncated), so neither `new Box($a)` nor `new Box($b)` infers → two fallbacks. + $collector = $this->check([ + 'Box.xphp' => " { public function __construct(private T \$v) {} }\nfinal class P { public function f(A \$a, B \$b): void { \$x = new Box(\$a); \$y = new Box(\$b); } }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertSame( + [Registry::CODE_MISSING_TYPE_ARGUMENT, Registry::CODE_MISSING_TYPE_ARGUMENT], + $codes, + ); + } + + public function testConstructorNameMatchedCaseInsensitively(): void + { + // PHP method names are case-insensitive; a `__Construct` constructor must still be found. + $collector = $this->check([ + 'Box.xphp' => " { public function __Construct(private T \$v) {} }\n", + 'Use.xphp' => "all(), 'a __Construct constructor is found case-insensitively, so T infers'); + } + // --- helpers (kept local, matching the other Monomorphize integration tests) --------------- /** @param array $files */ diff --git a/test/fixture/check/bare_new_missing_arg/source/Box.xphp b/test/fixture/check/bare_new_missing_arg/source/Box.xphp index 5b5942aa..92c767d1 100644 --- a/test/fixture/check/bare_new_missing_arg/source/Box.xphp +++ b/test/fixture/check/bare_new_missing_arg/source/Box.xphp @@ -6,7 +6,9 @@ namespace App\Check\BareNewMissingArg; class Box { - public function __construct(public T $v) + // T appears only in a method return, so a bare `new Box()` cannot infer it. + public function get(): ?T { + return null; } } diff --git a/test/fixture/check/bare_new_missing_arg/source/Use.xphp b/test/fixture/check/bare_new_missing_arg/source/Use.xphp index 220602fe..5b4f351f 100644 --- a/test/fixture/check/bare_new_missing_arg/source/Use.xphp +++ b/test/fixture/check/bare_new_missing_arg/source/Use.xphp @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Check\BareNewMissingArg; -// Bare `new` of a non-defaults generic, no turbofish: cannot pad from defaults. -// Silently emitting would leave the site pointing at the stripped marker -// `interface Box {}` -> a runtime "cannot instantiate interface" fatal. Rejected. -$b = new Box(5); +// Bare `new` whose type argument can't be inferred (T is not in a constructor +// parameter) and can't be defaulted: silently emitting would leave the site +// pointing at the stripped marker `interface Box {}` -> a runtime fatal. Rejected. +$b = new Box(); diff --git a/test/fixture/check/bare_new_missing_arg_qualified/source/Box.xphp b/test/fixture/check/bare_new_missing_arg_qualified/source/Box.xphp index d9f5523c..dfe0be12 100644 --- a/test/fixture/check/bare_new_missing_arg_qualified/source/Box.xphp +++ b/test/fixture/check/bare_new_missing_arg_qualified/source/Box.xphp @@ -6,7 +6,9 @@ namespace App\Check\BareNewMissingArgQualified; class Box { - public function __construct(public T $v) + // T appears only in a method return, so a bare `new Box()` cannot infer it. + public function get(): ?T { + return null; } } diff --git a/test/fixture/check/bare_new_missing_arg_qualified/source/Use.xphp b/test/fixture/check/bare_new_missing_arg_qualified/source/Use.xphp index 4f98c427..25080bf9 100644 --- a/test/fixture/check/bare_new_missing_arg_qualified/source/Use.xphp +++ b/test/fixture/check/bare_new_missing_arg_qualified/source/Use.xphp @@ -5,7 +5,7 @@ declare(strict_types=1); namespace App\Check\BareNewMissingArgQualified; // Fully-qualified and relative spellings must reject with the SAME verdict as the -// bare spelling -- both resolve to the same non-defaults template. Two diagnostics -// in one run (check collects, does not throw-on-first). -$a = new \App\Check\BareNewMissingArgQualified\Box(5); -$b = new namespace\Box(6); +// bare spelling -- both resolve to the same non-defaults, non-inferable template. +// Two diagnostics in one run (check collects, does not throw-on-first). +$a = new \App\Check\BareNewMissingArgQualified\Box(); +$b = new namespace\Box(); diff --git a/test/fixture/compile/inferred_new_arguments/source/Lib.xphp b/test/fixture/compile/inferred_new_arguments/source/Lib.xphp new file mode 100644 index 00000000..8a5b1846 --- /dev/null +++ b/test/fixture/compile/inferred_new_arguments/source/Lib.xphp @@ -0,0 +1,39 @@ + +{ + public function __construct(private T $value) + { + } + + public function get(): T + { + return $this->value; + } +} + +final class Holder +{ + private Plastic $p; + + public function __construct() + { + $this->p = new Plastic(); + } + + // `new Box($this->p)` — inferred from the declared property type. + public function boxProp(): Plastic + { + return (new Box($this->p))->get(); + } + + // `new Box($q)` — inferred from the declared parameter type. + public function boxParam(Plastic $q): Plastic + { + return (new Box($q))->get(); + } +} diff --git a/test/fixture/compile/inferred_new_arguments/source/Models.xphp b/test/fixture/compile/inferred_new_arguments/source/Models.xphp new file mode 100644 index 00000000..f168269f --- /dev/null +++ b/test/fixture/compile/inferred_new_arguments/source/Models.xphp @@ -0,0 +1,9 @@ +get(); +$boxObj = new Box(new Plastic()); +$objVal = $boxObj->get(); + +// Bare `new` inference from a class-typed property and a class-typed parameter. +$holder = new Holder(); +$fromProp = $holder->boxProp(); +$fromParam = $holder->boxParam(new Plastic()); diff --git a/test/fixture/compile/inferred_new_arguments/verify/runtime.php b/test/fixture/compile/inferred_new_arguments/verify/runtime.php new file mode 100644 index 00000000..faf886fd --- /dev/null +++ b/test/fixture/compile/inferred_new_arguments/verify/runtime.php @@ -0,0 +1,30 @@ +p`), and a + * class-typed parameter. + * + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. The user + * files aren't PSR-4, so require them in dependency order; the generated Box specializations are + * autoloaded. + */ + +use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Models.php'; + require $fixture->targetDir . '/Lib.php'; + require $fixture->targetDir . '/Use.php'; + + Assert::assertSame(5, $intVal, 'new Box(5) inferred T=int'); + Assert::assertInstanceOf('App\\NewInference\\Plastic', $objVal, 'new Box(new Plastic()) inferred T=Plastic'); + Assert::assertInstanceOf('App\\NewInference\\Plastic', $fromProp, 'new Box($this->p) inferred T=Plastic from the property type'); + Assert::assertInstanceOf('App\\NewInference\\Plastic', $fromParam, 'new Box($q) inferred T=Plastic from the parameter type'); +}; From 103df6b8eef8d5f9088619d8966b226d892867bf Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 29 Jul 2026 13:51:08 +0000 Subject: [PATCH 24/46] fix(monomorphize): reword the missing-turbofish diagnostic for inference Now that a bare generic call attempts inference, the free-function/closure diagnostic no longer claims a generic "takes no inference". It states the type arguments could not be inferred from the call arguments and to supply an explicit turbofish. The error code (xphp.missing_type_argument) and the collect-vs-throw behavior are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/GenericMethodCompiler.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index 7bed07ca..b11075b2 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -2172,8 +2172,8 @@ private function reportUnspecializableSelfCall(string $methodName, SourceLocatio 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::<...>(...)`.', + 'Generic call `%s(...)` is missing its type arguments: they could not be inferred ' + . 'from the call arguments, so call it with an explicit turbofish `%s::<...>(...)`.', $label, $label, ); From 4f7ca12f9a942bfc31b4416bf13b19d3a34d4435 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 29 Jul 2026 13:55:58 +0000 Subject: [PATCH 25/46] docs(inference): document optional turbofish via type-argument inference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flip the comparison grid's type-argument-inference row from ❌ to ⚠️ (inferred for calls and `new` where the arguments determine the type; explicit turbofish still required otherwise) and rewrite the accompanying prose — xphp now infers by default like Rust, reaching for the turbofish to disambiguate or where inference can't see the type. Drop the stale "no inference" note from the generic-functions/methods row. Add a "Type-argument inference is partial" caveat (what is and isn't inferred, why the argument-typing is conservative, and the turbofish workaround), a turbofish-syntax section describing inference and its sources, rewrite the mandatory-turbofish rule as optional-where-inferable, update the xphp.missing_type_argument error entry, and add a CHANGELOG entry. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 15 +++++++++ docs/caveats.md | 65 +++++++++++++++++++++++++++++++++++++++ docs/errors.md | 2 +- docs/guides/comparison.md | 48 ++++++++++++++++++----------- docs/syntax/turbofish.md | 53 +++++++++++++++++++++++++------ 5 files changed, 154 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10c2a3fa..b6d67b20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Type-argument inference (optional turbofish).** A generic call or `new` whose + type parameters are determined by the argument values no longer needs the `::<>` + turbofish: `identity(5)` infers `identity::`, `new Box($product)` infers + `new Box::`, `$factory->make($p)` and `$box->put($this->item)` infer + from the argument's type. Works for free functions, static and instance methods, + and class instantiation. An inferred call compiles to exactly the specialization + the turbofish would have selected — the type arguments are unified from the + arguments' static types and dispatched through the identical path, so bounds, + variance, mangling, and check/compile parity are unchanged. Argument types are + read conservatively (literals, `new`, `$this` properties, and non-reassigned + typed parameters); where they don't determine the type — a type parameter only in + the return type, an unknown argument type, or conflicting arguments — the explicit + turbofish is still required and omitting it remains the same `xphp.missing_type_argument` + error. Generic closure calls (`$f($x)`) and `T[]`-typed parameters are not yet + inference sources. See [turbofish → inference](docs/syntax/turbofish.md#type-argument-inference). - **Method-generic turbofish grounded by an enclosing type parameter.** A turbofish whose type argument is supplied by the enclosing generic scope now grounds **per specialization** and runs, instead of being rejected by the emitted-marker backstop: diff --git a/docs/caveats.md b/docs/caveats.md index b565be60..edf1c23b 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -7,6 +7,71 @@ each with the underlying reason and the workaround. Pages in the [syntax tour](syntax/) link back to specific sections here using anchor links — search this page for the same heading text. +## Type-argument inference is partial + +xphp infers a generic call's or `new`'s type arguments from the values you +pass, so the `::<>` turbofish is optional where the arguments determine the +type. But inference reads argument types conservatively — deliberately, so it +never emits a specialization the runtime value can't match — and where it +can't see a concrete type, you still write the turbofish. + +### ❌ What isn't inferred + +```php +function first(): T { /* ... */ } // T is only in the return type +$x = first(); // ✗ nothing to infer from — needs first::() + +function pair(T $a, T $b): array { /* ... */ } +$p = pair(1, 'x'); // ✗ int vs string disagree — needs pair::<...>() + +$val = $repo->find(); // a scalar-returning call assigned to a local +$b = new Box($val); // ✗ local-from-call isn't tracked — needs new Box::() + +function f(Fruit $x): void { + $x = pickAnother(); // $x reassigned... + $b = new Box($x); // ✗ reassigned param isn't trusted — needs the turbofish +} + +$g = function(T $x): T { return $x; }; +$g(5); // ✗ generic *closure* calls aren't inferred (deferred) +``` + +### ✅ What is inferred + +```php +identity(5); // ✓ T = int, from the literal +wrap(new Plastic()); // ✓ T = Plastic, from the `new` +Factory::make($p); // ✓ from $p's declared (class) type +$box->put($this->item); // ✓ from the declared property type +new Box(5); // ✓ T = int +new Pair($a, new Plastic()); // ✓ from a typed parameter + a `new` +``` + +Inference sources are: literals, `new X(...)`, `$this->prop` (from the declared +property type), and a plain parameter reference — but only a parameter with a +*concrete* declared type that is *never reassigned* in its function. A local +variable, a value from a call, a reassigned parameter, a union-typed value, or +a value typed by a still-abstract type parameter yields no inference. + +### Why + +Monomorphization needs the *concrete* type to pick a specialization, and an +inferred call must compile to exactly what the turbofish would have. So +inference only fires when it can prove the concrete type from the argument +itself: it derives the type arguments by unifying each parameter's declared +type against the argument's static type, then dispatches through the identical +path an explicit turbofish uses (same bounds, variance, and mangling). A value +whose type it can't prove statically — or can't prove *soundly*, like a +reassigned parameter — is left alone rather than guessed, because a wrong guess +would emit a specialization the runtime value fails to satisfy. + +### ✅ Workaround + +Write the explicit turbofish (`identity::(5)`, `new Box::($val)`) +wherever inference can't see the type. It's always accepted, and an inferred +call is identical to the turbofished one — so adding a turbofish never changes +behavior, only makes the type explicit. + ## `$this`-capturing arrows and closures rejected ### ❌ What doesn't work diff --git a/docs/errors.md b/docs/errors.md index 4230b83b..8e333072 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 — including a **turbofish-less call** to a generic method, function, or closure (`$x->pick('a')` instead of `$x->pick::('a')`), and a **bare `new` of a generic without all-defaults** (`new Box(...)` where `Box` has a required parameter, instead of `new Box::(...)`): the type argument takes no inference, so it must be supplied explicitly | +| `xphp.missing_type_argument` | a required type argument was omitted, has no default, and **could not be inferred from the call/constructor arguments** — e.g. a type parameter used only in the return type, an argument whose static type isn't known, or arguments that disagree. A turbofish-less call (`$x->pick('a')`) or bare `new` (`new Box(...)`) is fine when the arguments determine the type; when they don't, supply an explicit turbofish (`$x->pick::('a')`, `new Box::(...)`). See [turbofish → inference](syntax/turbofish.md#type-argument-inference) | | `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` | an `out T` / `in 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/guides/comparison.md b/docs/guides/comparison.md index af5b0b11..0e6404ff 100644 --- a/docs/guides/comparison.md +++ b/docs/guides/comparison.md @@ -25,10 +25,10 @@ than erasure can. | Feature | xphp | RFC | TS | Kotlin | Rust | |------------------------------------------|-------------------------|------------------|------------------|---------------|-----------------------| | Generic classes / interfaces / traits | ✅ | ✅ | ✅ | ✅ | ✅ | -| Generic functions / methods | ⚠️ (no inference; can't forward a method-level param, target another generic template, or use `static::`/`parent::`) | ✅ | ✅ | ✅ | ✅ | +| Generic functions / methods | ⚠️ (can't forward a method-level param, target another generic template, or use `static::`/`parent::`) | ✅ | ✅ | ✅ | ✅ | | Generic closures + arrow functions | ⚠️ (no `$this` capture or `static function` closures; reflection/serializers see the dispatcher rewrite) | ✅ | ✅ | ✅ | ✅ | | Typed closure signatures (`Closure(int): bool`) | ⚠️ (param/return/property only — not a generic arg or bound; erases to `\Closure`, literal conformance checked) | ❌ (only untyped `callable` / `\Closure`; noted as future work) | ✅ (function types) | ✅ (`(Int) -> Bool`) | ✅ (`Fn(i32) -> bool`) | -| Type-argument inference (call without `::<>`) | ❌ (explicit turbofish required) | ❌ (turbofish optional; omitting runs unvalidated) | ✅ | ✅ | ✅ (turbofish is the fallback) | +| Type-argument inference (call without `::<>`) | ⚠️ (inferred from the arguments for calls and `new` where they determine the type; otherwise the explicit turbofish is still required) | ❌ (turbofish optional; omitting runs unvalidated) | ✅ | ✅ | ✅ (turbofish is the fallback) | | Upper bounds | ✅ | ✅ | ✅ | ✅ | ✅ | | Multiple bounds (intersection) | ✅ | ✅ | ✅ | ✅ | ✅ | | Union bounds + DNF | ✅ | ✅ | ✅ | ❌ (intersection only via `where`) | n/a | @@ -54,18 +54,29 @@ mark features that simply can't exist under bound erasure: there are no specialized classes at runtime, so subtype edges, reified-T operations, and a wildcard sigil all lose their meaning. -**Type-argument inference.** No xphp generic call infers its type -arguments from the values passed — you always write the turbofish: -`identity::($x)`, `new Box::()`. Omitting it is a compile -error (`xphp.missing_type_argument`), because monomorphization needs -the concrete type to pick a specialization. TypeScript, Kotlin, and -Rust all infer instead. Rust is the closest comparison: xphp borrows -its `::<>` turbofish spelling exactly, but where Rust infers by -default and reaches for the turbofish only to disambiguate, xphp -makes it the only spelling. The bound-erasure RFC has no inference -either, yet diverges from xphp in the other direction — there the -turbofish is *optional*: omit it and the call runs unvalidated with -erased-to-`mixed` semantics rather than failing to compile. +**Type-argument inference.** xphp infers the type arguments from the +values passed when they determine the type, so the turbofish is +optional there: `identity(5)` infers `identity::`, `new Box($product)` +infers `new Box::`, `$factory->make($p)` infers from `$p`'s +type. An inferred call compiles to exactly the specialization the +turbofish would have selected — inference only writes the turbofish for +you, so bounds, variance, and mangling are unchanged. When the arguments +*don't* determine the type — a type parameter used only in the return +type, an argument whose static type isn't known, or two arguments that +disagree — you still write the turbofish, and omitting it is the same +compile error as before (`xphp.missing_type_argument`). Argument types are +read conservatively (literals, `new`, `$this` properties, and non-reassigned +typed parameters); generic *closure* calls (`$f($x)`) and `T[]`-typed +parameters are not yet inference sources and keep the explicit turbofish. +See [caveats](../caveats.md#type-argument-inference-is-partial). + +This is the same `::<>` turbofish Rust uses, and xphp now works like Rust +in spirit: infer by default, reach for the turbofish to disambiguate or +where inference can't see the type. TypeScript and Kotlin infer too. The +bound-erasure RFC has no inference, and diverges in the other direction — +there the turbofish is *optional* in a different sense: omit it and the +call runs unvalidated with erased-to-`mixed` semantics rather than +inferring or failing to compile. ## Where the monomorphic and erasure paths diverge @@ -144,10 +155,11 @@ reproduction and workaround) in [caveats](../caveats.md). parameter can't be defaulted or untyped ([caveat](../caveats.md#closure-signature-types-only-in-parameter-return-and-property-slots)). - **Generic functions / methods.** The base feature is solid; *composition* - is where the gaps are. There's no type-argument inference — the turbofish - is mandatory (see the grid row). And a turbofish grounded by an enclosing - type parameter can't forward a *method-level* parameter, target a - *different* generic template, or use the `static::`/`parent::` spellings + is where the gaps are. Type arguments are inferred where the call arguments + determine them, and otherwise the turbofish is required (see the grid row). + And a turbofish grounded by an enclosing type parameter can't forward a + *method-level* parameter, target a *different* generic template, or use the + `static::`/`parent::` spellings ([caveat](../caveats.md#generic-turbofish-grounded-by-an-enclosing-type-parameter)). Receiver-type tracking also gives up across branches that disagree on the type, and on a local assigned from a free function diff --git a/docs/syntax/turbofish.md b/docs/syntax/turbofish.md index 36a5bb85..3b62bb39 100644 --- a/docs/syntax/turbofish.md +++ b/docs/syntax/turbofish.md @@ -83,16 +83,49 @@ $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 (`$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. 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.) +- **The turbofish is optional where the arguments determine the type.** + A bare call or `new` whose type parameters are fixed by the argument + values is inferred — `$x->pick('a')` infers `$x->pick::('a')`, + `new Box(5)` infers `new Box::(5)` — and compiles to exactly the + specialization the turbofish would have selected (see + [inference](#type-argument-inference), below). When the arguments *don't* + determine the type — a type parameter only in the return type, an argument + whose static type isn't known, or arguments that disagree — a turbofish-less + call is a compile error (`xphp.missing_type_argument`), not a silent skip: + `xphp check` catches it at build time rather than letting it fatal at + runtime. A generic **method** whose type parameters are all defaulted may + still be called bare; a generic **closure** call (`$f($x)`) is not inferred + and always needs an explicit turbofish. (A first-class callable `pick(...)` + creates a closure rather than calling, and is left alone.) + +## Type-argument inference + +Where the arguments determine the type parameters, you can omit the turbofish +and xphp infers it: + +```php +$r = identity(5); // identity:: +$b = new Box(new Plastic()); // new Box:: +$m = Factory::make($product); // from $product's declared type +$d = $bag->put($this->item); // from the declared property type +``` + +Inference derives the type arguments by matching each parameter's declared +type against the argument's static type, then dispatches through the identical +path an explicit turbofish uses — so an inferred call is byte-for-byte the same +specialization, with the same bound and variance checks. It never *weakens* +anything: adding a turbofish to an inferred call can only make the type +explicit, never change behavior. + +Argument types are read conservatively: literals, `new X(...)`, `$this->prop` +(from the declared property type), and a plain parameter reference — but only a +parameter with a concrete declared type that is never reassigned in its +function. A local variable, a value returned from a call, a reassigned +parameter, a union-typed value, or a value typed by a still-abstract type +parameter is not an inference source, and such a call keeps the explicit +turbofish. Generic **closure** calls (`$f($x)`) and `T[]`-typed parameters are +not yet inferred either. See +[caveats](../caveats.md#type-argument-inference-is-partial). ## Receiver-type analysis (instance methods) From 763b1092a094ce10420370d6df370835606cecd4 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 29 Jul 2026 14:17:04 +0000 Subject: [PATCH 26/46] fix(monomorphize): don't infer a call argument typed by a type parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare generic call whose argument was typed by an in-scope type parameter (the enclosing function/method/class parameter, e.g. `identity($x)` inside `outer(U $x)`) inferred that parameter as a concrete class: the flow tracker stores a parameter's declared type as a resolved FQN (`App\U`), which typeOf() wrapped in a concrete TypeRef with no isTypeParam flag. The call then dispatched `identity::` and emitted a specialization referencing the non-existent class `App\U` — `check` reported clean, `compile` succeeded, and the program fataled at runtime. typeOf() (and typeOfThisProperty) now treat such a value as abstract and return null, so the site falls back to the exact missing-type-argument error it produced before inference existed. In-scope type-parameter names are tracked through the scope stack (enclosing functions/methods/closures) and read from the enclosing class, mirroring NewInferencePass. The explicit turbofish grounded by the enclosing parameter (`identity::($x)`) is unaffected and still grounds per specialization. Regression tests cover the argument-typed-by-a-function/method/class-type- parameter cases for the free-function, static, and instance seams, in both check and compile, and pin that the explicit-turbofish alternative still emits a concrete specialization with no reference to the abstract parameter. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/GenericMethodCompiler.php | 79 ++++++++++++++++++- .../GenericInferenceIntegrationTest.php | 73 +++++++++++++++++ 2 files changed, 148 insertions(+), 4 deletions(-) diff --git a/src/Transpiler/Monomorphize/GenericMethodCompiler.php b/src/Transpiler/Monomorphize/GenericMethodCompiler.php index b11075b2..3f210594 100644 --- a/src/Transpiler/Monomorphize/GenericMethodCompiler.php +++ b/src/Transpiler/Monomorphize/GenericMethodCompiler.php @@ -646,6 +646,18 @@ private function rewriteCallSites( * @var array */ private array $currentScopeClosureTemplates = []; + /** + * In-scope generic type-parameter names of the enclosing function/method/closure scopes, + * accumulated through nesting. A call/`new` argument whose declared type IS one of these + * is abstract here (it's a type parameter, not a concrete class), so it must NOT seed + * inference — otherwise a bare `identity($x)` inside `outer(U $x)` would infer + * `identity::` and emit a specialization referencing the non-existent class `U`. + * Class-level type parameters are read dynamically from {@see $currentClassFqn} in + * {@see typeOf}. Mirrors {@see NewInferencePass::enclosingTypeParamNames}. + * + * @var array + */ + private array $currentScopeTypeParamNames = []; /** * Parallel to `currentScopeClosureTemplates`: the Assign node and * lexical-scope info that introduced each generic anonymous template. @@ -696,7 +708,7 @@ private function rewriteCallSites( * 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}>, closureTemplates: array, closureContexts: array}> + * @var list, locals: array, paramArgs: array>, localArgs: array>, typeParamNames: array, branches: list, localArgsSnapshot: array>, assigned: array, perBranchTypes: list>, perBranchArgs: list>>, armIndex: int}>, closureTemplates: array, closureContexts: array}> */ private array $scopeSnapshots = []; /** @@ -785,6 +797,7 @@ public function primeDrainScope(?string $classFqn, string $namespace): void $this->currentScopeLocalTypes = []; $this->currentScopeParamTypeArgs = []; $this->currentScopeLocalTypeArgs = []; + $this->currentScopeTypeParamNames = []; $this->branchSnapshots = []; $this->scopeSnapshots = []; $this->currentScopeClosureTemplates = []; @@ -845,11 +858,13 @@ public function enterNode(Node $node): null|int $parentLocals = $this->currentScopeLocalTypes; $parentParamArgs = $this->currentScopeParamTypeArgs; $parentLocalArgs = $this->currentScopeLocalTypeArgs; + $parentTypeParamNames = $this->currentScopeTypeParamNames; $this->scopeSnapshots[] = [ 'params' => $parentParams, 'locals' => $parentLocals, 'paramArgs' => $parentParamArgs, 'localArgs' => $parentLocalArgs, + 'typeParamNames' => $parentTypeParamNames, '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 @@ -865,6 +880,18 @@ public function enterNode(Node $node): null|int $this->currentScopeClosureTemplates = []; $this->currentScopeClosureContexts = []; + // Type parameters accumulate through nesting: this scope sees the enclosing + // scopes' type params plus its own. Used to keep a type-param-typed argument + // from seeding inference (it's abstract here, not a concrete class). + $this->currentScopeTypeParamNames = $parentTypeParamNames; + $ownTypeParams = $node->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + if (is_array($ownTypeParams)) { + /** @var list $ownTypeParams */ + foreach ($ownTypeParams as $ownTypeParam) { + $this->currentScopeTypeParamNames[$ownTypeParam->name] = true; + } + } + // For closures: `use ($x)` explicitly imports outer variables. // Copy each imported name's type from the parent scope so the // closure body can specialize `$x->m::(...)` correctly. @@ -1153,6 +1180,7 @@ public function leaveNode(Node $node): ?Node $this->currentScopeLocalTypes = $snapshot['locals']; $this->currentScopeParamTypeArgs = $snapshot['paramArgs']; $this->currentScopeLocalTypeArgs = $snapshot['localArgs']; + $this->currentScopeTypeParamNames = $snapshot['typeParamNames']; $this->branchSnapshots = $snapshot['branches']; $this->currentScopeClosureTemplates = $snapshot['closureTemplates']; $this->currentScopeClosureContexts = $snapshot['closureContexts']; @@ -1165,6 +1193,7 @@ public function leaveNode(Node $node): ?Node $this->currentScopeLocalTypes = []; $this->currentScopeParamTypeArgs = []; $this->currentScopeLocalTypeArgs = []; + $this->currentScopeTypeParamNames = []; $this->branchSnapshots = []; $this->currentScopeClosureTemplates = []; $this->currentScopeClosureContexts = []; @@ -2683,7 +2712,9 @@ public function typeOf(Node\Expr $expr): ?TypeRef $fqn = $this->currentScopeParamTypes[$expr->name] ?? $this->currentScopeLocalTypes[$expr->name] ?? null; - if ($fqn === null) { + if ($fqn === null || $this->isInScopeTypeParam($fqn)) { + // Unknown, or the variable's declared type is an enclosing type parameter — + // abstract here, so not a concrete inference source. return null; } $args = $this->currentScopeParamTypeArgs[$expr->name] @@ -2700,11 +2731,45 @@ public function typeOf(Node\Expr $expr): ?TypeRef } if ($expr instanceof MethodCall || $expr instanceof NullsafeMethodCall || $expr instanceof StaticCall) { $return = $this->resolveCallReturn($expr); - return $return === null ? null : self::concreteOrNull(new TypeRef($return[0], $return[1])); + if ($return === null || $this->isInScopeTypeParam($return[0])) { + return null; + } + return self::concreteOrNull(new TypeRef($return[0], $return[1])); } return null; } + /** + * Whether a resolved type name refers to a generic type parameter in scope — an enclosing + * function/method/closure parameter ({@see $currentScopeTypeParamNames}) or an enclosing + * class parameter. Such a name is abstract at this site (a type parameter shadows a + * same-named class), so a value of that type must not seed inference — otherwise a bare + * `identity($x)` inside `outer(U $x)` would infer `identity::` and emit a call to a + * non-existent class. Mirrors {@see NewInferencePass}'s use of paramTypeRef's type-param set. + */ + private function isInScopeTypeParam(string $fqn): bool + { + $short = self::lastSegment(ltrim($fqn, '\\')); + if (isset($this->currentScopeTypeParamNames[$short])) { + return true; + } + if ($this->currentClassFqn === null) { + return false; + } + $classParams = $this->index->classLike($this->currentClassFqn) + ?->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + if (!is_array($classParams)) { + return false; + } + /** @var list $classParams */ + foreach ($classParams as $classParam) { + if ($classParam->name === $short) { + return true; + } + } + return false; + } + /** * The declared type of `$this->$propName` as a concrete TypeRef, or null when the class, * property, or its type cannot be determined (an unknown class, a promoted-constructor or @@ -2737,7 +2802,13 @@ private function typeOfThisProperty(string $propName): ?TypeRef $args = $type->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); /** @var list $argRefs */ $argRefs = is_array($args) ? $args : []; - return self::concreteOrNull(new TypeRef($this->resolveClassName($type), $argRefs)); + $fqn = $this->resolveClassName($type); + if ($this->isInScopeTypeParam($fqn)) { + // A property typed by the class's own type parameter (`private T $value` + // in `Box`) is abstract here — not a concrete inference source. + return null; + } + return self::concreteOrNull(new TypeRef($fqn, $argRefs)); } } return null; diff --git a/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php b/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php index ceb41aa9..df3f6c20 100644 --- a/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php @@ -165,6 +165,79 @@ public function testConflictingArgumentsFailCompile(): void ]); } + // --- soundness: a call argument typed by an in-scope type parameter must not infer ----------- + + public function testCallArgTypedByEnclosingFunctionTypeParamFallsBackInCheck(): void + { + // A bare call whose argument is typed by the ENCLOSING function's type parameter must not + // infer — that type is abstract here. Inferring would emit `identity::`, a call to the + // non-existent class `U`. It falls back to the missing-type-argument error instead. + $collector = $this->check([ + 'Lib.xphp' => "(T \$x): T { return \$x; }\nfunction outer(U \$x): U { \$r = identity(\$x); return \$x; }\n", + 'Use.xphp' => "(5);\n", + ]); + $codes = array_map(static fn (Diagnostic $d): string => $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testCallArgTypedByEnclosingFunctionTypeParamFailsCompile(): void + { + // Parity: compile throws the same error rather than emitting the broken specialization. + $this->expectException(RuntimeException::class); + $this->compile([ + 'Lib.xphp' => "(T \$x): T { return \$x; }\nfunction outer(U \$x): U { \$r = identity(\$x); return \$x; }\n", + 'Use.xphp' => "(5);\n", + ]); + } + + public function testCallArgTypedByMethodTypeParamFallsBack(): void + { + // Same, but the argument is typed by the enclosing METHOD's type parameter. + $collector = $this->check([ + 'Lib.xphp' => "(T \$x): T { return \$x; }\nfinal class Foo { public function m(W \$x): W { \$r = identity(\$x); return \$x; } }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testCallArgTypedByEnclosingClassTypeParamFallsBack(): void + { + // Same, but the argument is typed by the enclosing CLASS's type parameter. + $collector = $this->check([ + 'Lib.xphp' => "(T \$x): T { return \$x; }\nfinal class C { public function f(E \$e): void { \$r = identity(\$e); } }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testInstanceCallArgTypedByClassTypeParamFallsBack(): void + { + // The instance-method seam: `$this->prop`-less bare instance-generic call whose argument is + // typed by the class type parameter must not infer either. + $collector = $this->check([ + 'Lib.xphp' => "(T \$x): T { return \$x; } }\nfinal class C { public function f(E \$e): void { \$s = new Sink(); \$r = \$s->take(\$e); } }\n", + 'Use.xphp' => " $d->code, $collector->all()); + self::assertContains(Registry::CODE_MISSING_TYPE_ARGUMENT, $codes); + } + + public function testExplicitTurbofishGroundedByEnclosingParamStillWorks(): void + { + // The correct alternative — an explicit turbofish grounded by the enclosing type parameter — + // still grounds per specialization (unchanged by inference): `outer::` emits a call to + // a concrete `identity_T_`, never a reference to the abstract `U`. + $dist = $this->compile([ + 'Lib.xphp' => "(T \$x): T { return \$x; }\nfunction outer(U \$x): U { \$r = identity::(\$x); return \$x; }\n", + 'Use.xphp' => "(5);\n", + ]); + $lib = self::read($dist, 'Lib.php'); + self::assertStringContainsString('identity_T_', $lib); + self::assertStringNotContainsString('\\App\\U', $lib, 'no reference to the abstract type parameter U'); + } + // --- `new` inference ----------------------------------------------------------------------- private const BOX = <<<'PHP' From a4a35dd934ca19f19f6027e1294e2226431e6dff Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 29 Jul 2026 14:31:20 +0000 Subject: [PATCH 27/46] docs(inference): note the simple-name type-parameter collision fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inference is skipped when an argument's simple type name coincides with an in-scope type parameter (e.g. a class imported `as U` inside `f`); the name is treated as the shadowing type parameter and the call falls back to an explicit turbofish. It never mis-infers — document the conservative edge. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/caveats.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/caveats.md b/docs/caveats.md index edf1c23b..984470c4 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -53,6 +53,12 @@ property type), and a plain parameter reference — but only a parameter with a variable, a value from a call, a reassigned parameter, a union-typed value, or a value typed by a still-abstract type parameter yields no inference. +One conservative edge: inference is skipped when an argument's *simple* type +name coincides with an in-scope type parameter — e.g. a class imported as +`use Other\U as U` (or a same-named `U` in the current namespace) passed inside +`f(...)`. The name is treated as the type parameter (which shadows it), so +the call falls back. It never mis-infers — write the explicit turbofish there. + ### Why Monomorphization needs the *concrete* type to pick a specialization, and an From 540a7347c3328223d2f1fc57eba72dbccf6bde00 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 29 Jul 2026 17:18:28 +0000 Subject: [PATCH 28/46] fix(monomorphize): infer bare `new` bottom-up so nested generics match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A nested generic `new` was inferred with the wrong type argument: because the pass annotated on enterNode (top-down), an outer `new Box(new Box(5))` read its inner argument before that inner node had been annotated, so LiteralTyper saw no turbofish and typed the inner as the raw `Box` template — the outer inferred `Box` and emitted a different specialization than the explicit `new Box::>(new Box::(5))` would. Move the inference to leaveNode (bottom-up): inner nodes are fully annotated before their enclosing `new` reads them, so an inferred nested `new` now selects the byte-identical specialization the turbofish selects. The namespace context, class stack, and scope are still in place at a `New_` leave (they pop only when the enclosing class/function leaves, which is later). Regression tests: a nested `new Box(new Box(5))` now matches the turbofish's two specializations exactly; and — pinning the scope/class stack discipline the move relies on — property inference after a nested anonymous class and parameter inference after a nested closure both resolve against the correct outer scope. Docs: correct the inference-source list. A *call* additionally infers from a statically-tracked local (assigned from a `new` or a class-returning call) and from a call whose return type is a determinable class — it reuses the monomorphizer's receiver/flow tracking — while `new` inference stays limited to the conservative set. Adds a test for the class-returning-call source. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/caveats.md | 21 +++++-- docs/syntax/turbofish.md | 18 +++--- .../Monomorphize/NewInferencePass.php | 18 ++++-- .../GenericInferenceIntegrationTest.php | 57 +++++++++++++++++++ 4 files changed, 96 insertions(+), 18 deletions(-) diff --git a/docs/caveats.md b/docs/caveats.md index 984470c4..a261238e 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -43,15 +43,26 @@ identity(5); // ✓ T = int, from the literal wrap(new Plastic()); // ✓ T = Plastic, from the `new` Factory::make($p); // ✓ from $p's declared (class) type $box->put($this->item); // ✓ from the declared property type +wrap($factory->make()); // ✓ from make()'s class return type (call path) new Box(5); // ✓ T = int new Pair($a, new Plastic()); // ✓ from a typed parameter + a `new` ``` -Inference sources are: literals, `new X(...)`, `$this->prop` (from the declared -property type), and a plain parameter reference — but only a parameter with a -*concrete* declared type that is *never reassigned* in its function. A local -variable, a value from a call, a reassigned parameter, a union-typed value, or -a value typed by a still-abstract type parameter yields no inference. +Inference sources differ slightly between the two paths, because a **call** +reuses the monomorphizer's receiver/flow tracking while **`new`** runs a +lighter standalone pass: + +- **Calls** infer from: literals, `new X(...)`, `$this->prop` (declared type), + a plain parameter, a local whose type is statically tracked (assigned from a + `new` or a class-returning call), and a call whose declared return type is a + determinable class. +- **`new`** infers from the conservative set only: literals, `new X(...)`, + `$this->prop`, and a non-reassigned typed parameter — not locals or call + returns. + +In both, a reassigned parameter, a *scalar*-returning-call value held in a +local, a union-typed value, or a value typed by a still-abstract type parameter +yields no inference. One conservative edge: inference is skipped when an argument's *simple* type name coincides with an in-scope type parameter — e.g. a class imported as diff --git a/docs/syntax/turbofish.md b/docs/syntax/turbofish.md index 3b62bb39..f80cf951 100644 --- a/docs/syntax/turbofish.md +++ b/docs/syntax/turbofish.md @@ -117,14 +117,16 @@ specialization, with the same bound and variance checks. It never *weakens* anything: adding a turbofish to an inferred call can only make the type explicit, never change behavior. -Argument types are read conservatively: literals, `new X(...)`, `$this->prop` -(from the declared property type), and a plain parameter reference — but only a -parameter with a concrete declared type that is never reassigned in its -function. A local variable, a value returned from a call, a reassigned -parameter, a union-typed value, or a value typed by a still-abstract type -parameter is not an inference source, and such a call keeps the explicit -turbofish. Generic **closure** calls (`$f($x)`) and `T[]`-typed parameters are -not yet inferred either. See +Argument types are read from: literals, `new X(...)`, `$this->prop` (declared +type), and a plain parameter with a concrete declared type that isn't reassigned. +A **call** additionally infers from a statically-tracked local (assigned from a +`new` or a class-returning call) and from a call whose declared return type is a +determinable class, because the call path reuses the monomorphizer's receiver/flow +tracking; **`new`** inference is limited to the conservative set (no locals or call +returns). A reassigned parameter, a scalar-returning-call value, a union-typed +value, or a value typed by a still-abstract type parameter is never an inference +source, and such a site keeps the explicit turbofish. Generic **closure** calls +(`$f($x)`) and `T[]`-typed parameters are not yet inferred either. See [caveats](../caveats.md#type-argument-inference-is-partial). ## Receiver-type analysis (instance methods) diff --git a/src/Transpiler/Monomorphize/NewInferencePass.php b/src/Transpiler/Monomorphize/NewInferencePass.php index 8615d34a..d13007d7 100644 --- a/src/Transpiler/Monomorphize/NewInferencePass.php +++ b/src/Transpiler/Monomorphize/NewInferencePass.php @@ -97,17 +97,23 @@ public function enterNode(Node $node): null if ($node instanceof FunctionLike) { $this->scopes[] = $this->scopeForFunction($node); } + return null; + } + + public function leaveNode(Node $node): null + { + // Infer on leave (bottom-up): a nested `new` argument must be annotated with its own + // inferred type arguments BEFORE its enclosing `new` reads it, or `new Box(new Box(5))` + // would type the inner as the raw `Box` template and infer `Box` instead of + // `Box>` — a specialization the explicit turbofish would never produce. The + // namespace context, class stack, and scope are still in place here: those are popped + // only when the enclosing ClassLike/FunctionLike leaves, which is strictly later. if ($node instanceof New_ && $node->class instanceof Name && $node->class->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS) === null ) { $this->tryInferNew($node->class, $node); } - return null; - } - - public function leaveNode(Node $node): null - { if ($node instanceof FunctionLike) { array_pop($this->scopes); } @@ -267,6 +273,8 @@ private static function typeParamNamesOf(ClassLike $class): array if (is_array($params)) { /** @var list $params */ foreach ($params as $param) { + // @infection-ignore-all TrueValue -- $names is a set; membership is tested with + // isset() in TypeInference::paramTypeRef, so the stored value is immaterial. $names[$param->name] = true; } } diff --git a/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php b/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php index df3f6c20..c4a91f95 100644 --- a/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php +++ b/test/Transpiler/Monomorphize/GenericInferenceIntegrationTest.php @@ -274,6 +274,39 @@ public function testBareNewInfersFromLiteral(): void self::assertStringContainsString('Generated\\App\\Box\\T_', self::read($dist, 'Use.php')); } + public function testNestedGenericNewInfersSameSpecializationAsTurbofish(): void + { + // `new Box(new Box(5))` must infer `Box>` — the SAME specialization the explicit + // turbofish selects — not `Box` read off an un-annotated inner node. Regression for the + // bottom-up (leaveNode) annotation order. + $box = " { public function __construct(private T \$v) {} public function get(): T { return \$this->v; } }\n"; + $inferred = self::read($this->compile([ + 'Box.xphp' => $box, + 'Use.xphp' => "compile([ + 'Box.xphp' => $box, + 'Use.xphp' => ">(new Box::(5));\n", + ]), 'Use.php'); + self::assertSame(2, preg_match_all('/Box\\\\T_\w+/', $explicit, $e)); + self::assertSame(2, preg_match_all('/Box\\\\T_\w+/', $inferred, $i)); + // The inferred outer + inner specializations are exactly the two the turbofish produces. + self::assertSame($e[0], $i[0], 'inferred nested new selects the same specializations as the turbofish'); + self::assertCount(2, array_unique($i[0]), 'outer and inner are distinct specializations'); + } + + public function testCallInfersFromAClassReturningCallArgument(): void + { + // A call argument that is itself a call returning a determinable class is an inference source + // for the call path (it reuses the receiver flow engine): `identity($f->make())` infers + // T=Plastic. (The `new` pass is more conservative and would not.) + $dist = $this->compile([ + 'Lib.xphp' => "(T \$x): T { return \$x; }\nfinal class Factory { public function make(): Plastic { return new Plastic(); } }\nfinal class R { public function go(Factory \$f): Plastic { return identity(\$f->make()); } }\n", + 'Use.xphp' => "check([ @@ -304,6 +337,30 @@ public function testNonInferableBareNewThrowsInCompile(): void ]); } + public function testParameterInferenceUsesTheRightScopeAfterANestedClosure(): void + { + // After a nested closure closes, `$op` must resolve against the OUTER function's scope — + // i.e. the scope stack is popped on leave. `new Box($op)` infers Box; without the + // pop it would read the closure's scope (which has $wp, not $op) and fail to infer. + $dist = $this->compile([ + 'Lib.xphp' => " { public function __construct(private T \$v) {} public function get(): T { return \$this->v; } }\nfunction outer(Plastic \$op): Plastic { \$f = function (Widget \$wp): Widget { return \$wp; }; \$unused = \$f; \$b = new Box(\$op); return \$b->get(); }\n", + 'Use.xphp' => "ap` must resolve against the OUTER class + // A — i.e. the class stack is popped on leave. `new Box($this->ap)` infers Box; + // without the pop it would scan the anon class (which has no `$ap`) and fail to infer. + $dist = $this->compile([ + 'Lib.xphp' => " { public function __construct(private T \$v) {} public function get(): T { return \$this->v; } }\nfinal class A { private Plastic \$ap; public function __construct() { \$this->ap = new Plastic(); } public function m(): Plastic { \$inner = new class { private ?Widget \$wp = null; public function n(): ?Widget { return \$this->wp; } }; \$b = new Box(\$this->ap); return \$b->get(); } }\n", + 'Use.xphp' => " Date: Thu, 30 Jul 2026 08:30:31 +0000 Subject: [PATCH 29/46] feat(monomorphize): strip type-alias declarations at scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recognize a `type Name[] = SingleHeadBody;` declaration in the scanner and blank it to equal-length whitespace, so the (otherwise invalid) statement never reaches the host PHP parser. The alias arm runs first — ahead of the bare `Name<…>` arm — so the `` clauses on the alias head and its body are not half-stripped. This is the recognition + strip step only: the alias body is not yet captured or expanded (that follows). Statement-position gated so `type` used as a constant, function, or member name is never mistaken for a declaration. Single-head bodies only; a union / intersection / nullable body, or any other non-single-head shape, declines here and falls through (to become an explicit diagnostic in a later change). The separator must be `=`; the param list is parsed permissively. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 107 +++++++++++ .../Monomorphize/XphpSourceParserTest.php | 176 ++++++++++++++++++ 2 files changed, 283 insertions(+) diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index bf5c9e12..00fe193d 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -328,6 +328,25 @@ private function scanAndStrip(string $source): array while ($i < $n) { $tok = $tokens[$i]; + // Type-alias declaration: `type Name[] = SingleHeadBody;` (WI-01, file-local). + // `type` is a contextual keyword (an ordinary T_STRING), so this arm MUST run first — + // before the bare `Name<…>` arm below, which would otherwise strip the `` off + // `type Pair = …` and leave the statement half-parsed. `tryParseAliasDeclaration` + // gates on statement position (so a `type` used as a constant / function / member name + // is never mistaken for a declaration) and consumes the WHOLE `type … ;` statement, + // blanking it to equal-length whitespace (the alias has no runtime existence). + if ($tok->id === T_STRING && $tok->text === 'type') { + $semicolonIdx = self::tryParseAliasDeclaration($tokens, $i); + if ($semicolonIdx !== null) { + $startByte = $tok->pos; + $endByte = $tokens[$semicolonIdx]->pos + strlen($tokens[$semicolonIdx]->text); + $length = $endByte - $startByte; + $replacements[] = [$startByte, $length, self::blank(substr($source, $startByte, $length))]; + $i = $semicolonIdx + 1; + continue; + } + } + // Anonymous closure: `function(...){}` / `fn(...)`. // Recognized by T_FUNCTION/T_FN followed immediately by `<` (no // T_STRING name). `static`-prefixed shapes are consumed by the @@ -2385,6 +2404,94 @@ private static function parseTypeArgList(array $tokens, int $openIdx): ?array return null; } + /** + * Recognize a type-alias declaration `type Name [] = SingleHeadBody;` beginning at the + * `type` token index `$typeIdx`, and return the index of its terminating `;` — or null when the + * tokens are not a well-formed single-head alias declaration, so the `type` token falls through + * to ordinary handling (a genuinely malformed shape then reaches nikic / the validators; nothing + * is silently eaten). + * + * v1 (WI-01): file-local; the body must be a single (possibly-generic) head that `parseTypeArg` + * accepts. A union / intersection / nullable / closure body leaves a non-`;` token after the head + * and is declined here — a dedicated `xphp.alias_unsupported_body` diagnostic lands in a later + * change rather than a silent pass-through. + * + * Gated to STATEMENT position: the previous significant token must be a statement boundary + * (`;`, `{`, `}`, or the opening `type`, `new type()`) is never mistaken for a declaration. + * + * @param list $tokens + */ + private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ?int + { + // Statement-position guard. `type` always sits at index >= 1 (index 0 is the open tag), so + // skipWsBack lands on a real token; the `?? null` is a defensive floor only. A `type` used as + // a constant / function / member name (preceded by `->`, `::`, `=`, `(`, …) is declined here. + $prevTok = $tokens[self::skipWsBack($tokens, $typeIdx - 1)] ?? null; + if ($prevTok === null + || !($prevTok->id === T_OPEN_TAG + || $prevTok->text === ';' + || $prevTok->text === '{' + || $prevTok->text === '}') + ) { + return null; + } + + // Alias name. + // @infection-ignore-all IncrementInteger -- `type` is always followed by whitespace (else + // `typeName` would tokenize as one T_STRING), so skipWs(+1) and skipWs(+2) reach the same + // name token: the offset increment is an equivalent mutant. + $nameIdx = self::skipWs($tokens, $typeIdx + 1); + $nameTok = $tokens[$nameIdx] ?? null; + if ($nameTok === null || $nameTok->id !== T_STRING) { + return null; + } + + // Optional `` parameter list. Parsed permissively (defaults + variance allowed, as on + // a class header) so recognition never throws; whether an alias param may carry a default or + // variance marker is a semantic question for the expansion step, not for scan-time stripping. + $afterName = self::skipWs($tokens, $nameIdx + 1); + $afterNameTok = $tokens[$afterName] ?? null; + if ($afterNameTok === null) { + return null; + } + if ($afterNameTok->text === '<') { + $parsed = self::parseTypeParamList($tokens, $afterName, allowDefaults: true, allowVariance: true); + if ($parsed === null) { + return null; + } + [, $paramsEndIdx] = $parsed; + // @infection-ignore-all IncrementInteger -- the `>` closing the param list is followed + // by whitespace-then-`=` in every reachable shape (a no-space `>=` is the comparison + // operator, not this position), so skipWs(+1) and skipWs(+2) reach the same token. + $eqIdx = self::skipWs($tokens, $paramsEndIdx + 1); + } else { + $eqIdx = $afterName; + } + + // `=`. + if (($tokens[$eqIdx] ?? null)?->text !== '=') { + return null; + } + + // Single (possibly-generic) head body. A union / intersection / nullable / closure body + // leaves a non-`;` token after the head and is declined for v1 (a later change turns that + // into an explicit `xphp.alias_unsupported_body` diagnostic rather than a silent decline). + $bodyParsed = self::parseTypeArg($tokens, self::skipWs($tokens, $eqIdx + 1)); + if ($bodyParsed === null) { + return null; + } + [, $afterBody] = $bodyParsed; + + // Terminating `;` (a single-head body leaves it immediately after the head). + $semiIdx = self::skipWs($tokens, $afterBody); + if (($tokens[$semiIdx] ?? null)?->text !== ';') { + return null; + } + + return $semiIdx; + } + /** * Parse a single type arg: `NAME ( < TypeArgList > )?`. * diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index b35b9d2b..a0ed6357 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -85,6 +85,169 @@ trait HasTimestamps self::assertStringNotContainsString('trait HasTimestamps', $printed); } + public function testTypeAliasDeclarationsAreStrippedAndParseCleanly(): void + { + // WI-01 (Commit 1): a `type Name[<…>] = SingleHead;` declaration is recognized at scan and + // blanked to equal-length whitespace, so the (otherwise invalid) statement never reaches + // nikic. The alias arm MUST run before the bare `Name<…>` arm, or the `` clauses on + // `Pair`/`Map` get half-stripped and the RHS is left dangling (CRITICAL-4, design review). + $source = <<<'PHP' + = Map; +type UserId = \App\Id; +type Ints = Bag; + +class Repo +{ +} +PHP; + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + + // Each declaration span becomes equal-length whitespace; everything else is byte-identical. + self::assertSame( + self::withBlanked( + $source, + 'type Pair = Map;', + 'type UserId = \App\Id;', + 'type Ints = Bag;', + ), + $parser->strip($source), + ); + + // The whole file still parses; the alias statements are gone, the class remains. + $class = self::findFirstClass($parser->parse($source)); + self::assertNotNull($class); + self::assertSame('Repo', $class->name?->toString()); + } + + public function testNonGenericTypeAliasWithoutParamsIsStripped(): void + { + // The no-`<…>` shape (`type Name = Body;`) must strip too, and the trailing statement + // survives untouched. + $source = "createForHostVersion()); + + self::assertSame(self::withBlanked($source, 'type UserId = int;'), $parser->strip($source)); + $parser->parse($source); // must not throw + } + + /** + * A type-alias declaration is recognized at every statement boundary (open tag, `;`, `{`, `}`), + * including with no separating whitespace — the statement-position guard must accept each. + */ + public function testTypeAliasRecognizedAtEveryStatementBoundary(): void + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + + // `{` boundary, no space before `type` (guards the skipWsBack offset + the `{` branch). + $braceOpen = "strip($braceOpen)); + + // `}` boundary, right after a class close (guards the `}` branch). + $braceClose = "strip($braceClose)); + + // `;` boundary — a second alias directly after the first; both spans are blanked. + $semi = "strip($semi), + ); + + // Parameter lists are parsed permissively (defaults + variance), so these are recognized + // and blanked rather than throwing — the `allowDefaults` / `allowVariance` flags. + $defaulted = " = Bag;\n"; + self::assertSame(self::withBlanked($defaulted, 'type P = Bag;'), $parser->strip($defaulted)); + $variant = " = Bag;\n"; + self::assertSame(self::withBlanked($variant, 'type B = Bag;'), $parser->strip($variant)); + } + + /** + * `type` is a contextual keyword: only a statement-position `type Name = …` is a declaration. + * A property/constant/expression use named `type` must be left byte-for-byte untouched — and a + * `type` reached in a non-statement position must never be read as a declaration head even when + * a `Name = Body` pattern follows it. + */ + public function testTypeOutsideStatementPositionIsNeverAnAliasDeclaration(): void + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + + $memberish = "type;\n\$b = Foo::type;\n"; + self::assertSame($memberish, $parser->strip($memberish), '`type` in member/constant position must not be stripped'); + + // `->type Foo = int` is not a statement; without the guard it would be mis-read as a + // declaration and wrongly stripped. It must be left intact. + $notADecl = "type Foo = int;\n"; + self::assertSame($notADecl, $parser->strip($notADecl)); + + $parser->parse($memberish); // must not throw + } + + /** + * v1 recognizes only a single (possibly-generic) head body. A union / intersection / nullable + * body is declined at scan (left intact), to become an explicit diagnostic in a later change — + * it must never be half-stripped. + */ + public function testNonSingleHeadAliasBodiesAreDeclined(): void + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + + // Union / nullable bodies: parseTypeArg stops at `|` / returns null on `?`, leaving a + // non-`;` token after the head, so the declaration is declined and left byte-for-byte intact. + $union = "strip($union)); + $nullable = "strip($nullable)); + // The alias name must be a real identifier (T_STRING). A reserved word like `array` + // (T_ARRAY) is not a valid alias head, so the declaration is declined and left intact. + $reserved = "strip($reserved)); + } + + /** + * Whitespace around the `=` and after the head is optional — a tightly-spelled `type X=Y;` is + * recognized and stripped just like the spaced form. And a generic-parameter head that runs out + * at end of input (no `=`) is declined without crashing on the (missing) `=` token. + */ + public function testTightlySpelledAndGenericEofAliasShapes(): void + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + + // No spaces around `=` — still a single-head body, recognized and blanked. + $tight = "strip($tight)); + + // The separator must be `=`; the rejected colon spelling `type X : Foo` is declined intact. + $colon = "strip($colon)); + + // Generic params then EOF (no `=`): the alias arm declines at the `=` check without + // dereferencing the missing token; only the bare `` is cleaned by the downstream name + // arm, so the `type X` head survives. + $eof = ""; + self::assertSame(self::withBlanked($eof, ''), $parser->strip($eof)); + } + + /** + * A truncated / unterminated `type …` at end of input is declined without crashing — the token + * stream simply runs out at each parse step. Guards the end-of-stream floors in the recognizer + * (each step's `?? null`), which the tolerant LSP path relies on for half-typed code. + */ + public function testTruncatedTypeAliasAtEndOfInputIsDeclinedWithoutCrashing(): void + { + $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + + foreach ([ + "strip($truncated), 'truncated alias must be left intact'); + } + } + public function testAttachesGenericParamsToTraitDefinition(): void { // Traits ride the same ClassLike pathway as classes/interfaces. Locks the @@ -1042,6 +1205,19 @@ private static function paramNames(\PhpParser\Node\Stmt\ClassLike $node): array return array_map(static fn (TypeParam $p): string => $p->name, $params); } + /** + * Return `$source` with each (single-line) `$span` replaced by equal-length spaces — the exact + * transformation `XphpSourceParser::strip()` applies to a recognized declaration. Lets a strip + * assertion state the full expected output deterministically rather than a substring check. + */ + private static function withBlanked(string $source, string ...$spans): string + { + foreach ($spans as $span) { + $source = str_replace($span, str_repeat(' ', strlen($span)), $source); + } + return $source; + } + /** @param array $ast */ private static function findFirstClass(array $ast): ?Class_ { From 5c0302c3b674a12a0c2bb79c1927130d500934b5 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 30 Jul 2026 12:18:17 +0000 Subject: [PATCH 30/46] feat(monomorphize): expand type-alias uses before specialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture each `type Name[<…>] = SingleHead;` declaration and expand its uses into the alias body before specialization, so nothing downstream (registry, specializer, call-site rewriter) ever sees an alias and the emitted PHP contains no alias name. - Build a file-local alias table keyed by FQN, attributing each alias to its declaring namespace by byte span (a real class sharing an alias's short name in another namespace never collides). - Expand in the resolver's Name branch: the head AND, recursively, the arguments (an alias can appear as a generic argument, e.g. Bag), substituting parameters via the resolved body. Nested and concrete-instantiation aliases (UserMap = Pair) resolve fully; a non-alias name is left untouched. - Reject a self-referential (cyclic) or arity-mismatched alias loudly in both modes: compile throws, check collects the diagnostic. Aliases are a pure compile-time substitution with no runtime existence; v1 is file-local and single-head-bodied. A runtime fixture executes the compiled output and asserts every alias name is absent. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 268 +++++++++++++++-- .../Monomorphize/TypeAliasIntegrationTest.php | 281 ++++++++++++++++++ .../compile/type_aliases/source/Types.xphp | 51 ++++ .../compile/type_aliases/verify/runtime.php | 38 +++ 4 files changed, 619 insertions(+), 19 deletions(-) create mode 100644 test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php create mode 100644 test/fixture/compile/type_aliases/source/Types.xphp create mode 100644 test/fixture/compile/type_aliases/verify/runtime.php diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 00fe193d..81cc5ef1 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -151,7 +151,7 @@ public function parse(string $source): array */ public function parseWithMap(string $source): array { - [$classMarkers, $nameMarkers, $methodMarkers, $cleanedSource, $byteOffsetMap, $closureMarkers] = $this->scanAndStrip($source); + [$classMarkers, $nameMarkers, $methodMarkers, $cleanedSource, $byteOffsetMap, $closureMarkers, $aliasMarkers] = $this->scanAndStrip($source); try { $ast = $this->parser->parse($cleanedSource); @@ -169,7 +169,7 @@ public function parseWithMap(string $source): array } /** @var list $ast — nikic's parse() returns array; runtime keys are always 0..N-1. */ - $unbound = $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap); + $unbound = $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap, $aliasMarkers); // @infection-ignore-all — defensive backstop, unreachable from valid input by // construction (see unboundDeclarationMarkerMessage): no test can reach a // mutant here. The message builder is pinned by direct unit tests; this @@ -273,7 +273,7 @@ public function parseTolerant(string $source): ?array */ public function parseTolerantWithMap(string $source): ?ParseWithMapResult { - [$classMarkers, $nameMarkers, $methodMarkers, $cleanedSource, $byteOffsetMap, $closureMarkers] = $this->scanAndStrip($source); + [$classMarkers, $nameMarkers, $methodMarkers, $cleanedSource, $byteOffsetMap, $closureMarkers, $aliasMarkers] = $this->scanAndStrip($source); $errorHandler = new \PhpParser\ErrorHandler\Collecting(); $ast = $this->parser->parse($cleanedSource, $errorHandler); @@ -282,7 +282,7 @@ public function parseTolerantWithMap(string $source): ?ParseWithMapResult } /** @var list $ast — nikic's parse() returns array; runtime keys are always 0..N-1. */ - $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap); + $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap, $aliasMarkers); return new ParseWithMapResult($ast, $byteOffsetMap); } @@ -307,7 +307,7 @@ public function strip(string $source): string } /** - * @return array{0: list}>, 1: list}>, 2: list}>, 3: string, 4: ByteOffsetMap, 5: list} + * @return array{0: list}>, 1: list}>, 2: list}>, 3: string, 4: ByteOffsetMap, 5: list, 6: list, body:TypeRef, bytePosition:int}>} */ private function scanAndStrip(string $source): array { @@ -321,6 +321,8 @@ private function scanAndStrip(string $source): array $methodMarkers = []; /** @var list $closureMarkers */ $closureMarkers = []; + /** @var list, body:TypeRef, bytePosition:int}> $aliasMarkers */ + $aliasMarkers = []; /** @var list $replacements [byte offset, original length, replacement text] */ $replacements = []; @@ -336,8 +338,10 @@ private function scanAndStrip(string $source): array // is never mistaken for a declaration) and consumes the WHOLE `type … ;` statement, // blanking it to equal-length whitespace (the alias has no runtime existence). if ($tok->id === T_STRING && $tok->text === 'type') { - $semicolonIdx = self::tryParseAliasDeclaration($tokens, $i); - if ($semicolonIdx !== null) { + $aliasParsed = self::tryParseAliasDeclaration($tokens, $i); + if ($aliasParsed !== null) { + [$aliasMarker, $semicolonIdx] = $aliasParsed; + $aliasMarkers[] = $aliasMarker; $startByte = $tok->pos; $endByte = $tokens[$semicolonIdx]->pos + strlen($tokens[$semicolonIdx]->text); $length = $endByte - $startByte; @@ -847,7 +851,7 @@ private function scanAndStrip(string $source): array $cleaned = self::applyReplacements($source, $replacements); $byteOffsetMap = ByteOffsetMap::fromReplacements($replacements); - return [$classMarkers, $nameMarkers, $methodMarkers, $cleaned, $byteOffsetMap, $closureMarkers]; + return [$classMarkers, $nameMarkers, $methodMarkers, $cleaned, $byteOffsetMap, $closureMarkers, $aliasMarkers]; } /** @@ -2406,10 +2410,12 @@ private static function parseTypeArgList(array $tokens, int $openIdx): ?array /** * Recognize a type-alias declaration `type Name [] = SingleHeadBody;` beginning at the - * `type` token index `$typeIdx`, and return the index of its terminating `;` — or null when the - * tokens are not a well-formed single-head alias declaration, so the `type` token falls through - * to ordinary handling (a genuinely malformed shape then reaches nikic / the validators; nothing - * is silently eaten). + * `type` token index `$typeIdx`, and return `[marker, semicolonIndex]` — or null when the tokens + * are not a well-formed single-head alias declaration, so the `type` token falls through to + * ordinary handling (a genuinely malformed shape then reaches nikic / the validators; nothing is + * silently eaten). The marker carries the alias short name, its (possibly empty) type-parameter + * names, the raw body TypeRef (resolved later against the namespace context), and the `type` + * token's byte position for namespace-span attribution. * * v1 (WI-01): file-local; the body must be a single (possibly-generic) head that `parseTypeArg` * accepts. A union / intersection / nullable / closure body leaves a non-`;` token after the head @@ -2421,8 +2427,9 @@ private static function parseTypeArgList(array $tokens, int $openIdx): ?array * a member (`Foo::type`, `$x->type`, `new type()`) is never mistaken for a declaration. * * @param list $tokens + * @return array{0: array{name:string, paramNames:list, body:TypeRef, bytePosition:int}, 1: int}|null */ - private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ?int + private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ?array { // Statement-position guard. `type` always sits at index >= 1 (index 0 is the open tag), so // skipWsBack lands on a real token; the `?? null` is a defensive floor only. A `type` used as @@ -2450,6 +2457,7 @@ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ? // Optional `` parameter list. Parsed permissively (defaults + variance allowed, as on // a class header) so recognition never throws; whether an alias param may carry a default or // variance marker is a semantic question for the expansion step, not for scan-time stripping. + $paramNames = []; $afterName = self::skipWs($tokens, $nameIdx + 1); $afterNameTok = $tokens[$afterName] ?? null; if ($afterNameTok === null) { @@ -2460,7 +2468,8 @@ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ? if ($parsed === null) { return null; } - [, $paramsEndIdx] = $parsed; + [$paramEntries, $paramsEndIdx] = $parsed; + $paramNames = array_map(static fn (array $entry): string => $entry['name'], $paramEntries); // @infection-ignore-all IncrementInteger -- the `>` closing the param list is followed // by whitespace-then-`=` in every reachable shape (a no-space `>=` is the comparison // operator, not this position), so skipWs(+1) and skipWs(+2) reach the same token. @@ -2481,7 +2490,7 @@ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ? if ($bodyParsed === null) { return null; } - [, $afterBody] = $bodyParsed; + [$body, $afterBody] = $bodyParsed; // Terminating `;` (a single-head body leaves it immediately after the head). $semiIdx = self::skipWs($tokens, $afterBody); @@ -2489,7 +2498,15 @@ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ? return null; } - return $semiIdx; + return [ + [ + 'name' => $nameTok->text, + 'paramNames' => $paramNames, + 'body' => $body, + 'bytePosition' => $tokens[$typeIdx]->pos, + ], + $semiIdx, + ]; } /** @@ -2783,6 +2800,58 @@ private static function applyReplacements(string $source, array $replacements): return $source; } + /** + * Build the file-local type-alias table, keyed by fully-qualified name. Each alias's declaring + * namespace is found by locating the `Namespace_` node whose (original-source) byte span contains + * the `type` keyword, so a real class sharing an alias's short name in another namespace never + * collides. Bodies stay raw (unresolved) — they resolve lazily at expansion, when the use-site + * namespace context is available. A duplicate FQN keeps the last declaration (a dedicated + * duplicate-alias diagnostic lands in a later change). + * + * @param list $ast + * @param list, body:TypeRef, bytePosition:int}> $aliasMarkers + * @return array, body:TypeRef}> + */ + private static function buildAliasTable(array $ast, array $aliasMarkers, ByteOffsetMap $byteOffsetMap): array + { + // @infection-ignore-all ReturnRemoval -- optimization only: with no markers the loops below + // produce an empty table anyway; the early return just skips the namespace-span walk for the + // common alias-free file. + if ($aliasMarkers === []) { + return []; + } + /** @var list $spans namespace name + original byte span */ + $spans = []; + foreach ($ast as $stmt) { + if ($stmt instanceof Namespace_) { + $spans[] = [ + $stmt->name?->toString() ?? '', + $byteOffsetMap->toOriginal($stmt->getStartFilePos()), + $byteOffsetMap->toOriginal($stmt->getEndFilePos()), + ]; + } + } + $table = []; + foreach ($aliasMarkers as $marker) { + $namespace = ''; + foreach ($spans as [$name, $start, $end]) { + // @infection-ignore-all GreaterThanOrEqualTo LessThanOrEqualTo -- a `type` keyword's + // byte sits strictly inside its namespace span (after the `namespace` keyword, before + // the closing brace / EOF), so the `>=`/`<=` boundary variants never shift attribution; + // the `&&` (a use in an earlier namespace must not match a later one) is exercised. + if ($marker['bytePosition'] >= $start && $marker['bytePosition'] <= $end) { + $namespace = $name; + // @infection-ignore-all Break_ -- namespace spans are disjoint, so no later span + // can also contain this byte; continuing the loop is equivalent. + break; + } + } + $fqn = $namespace === '' ? $marker['name'] : $namespace . '\\' . $marker['name']; + $table[$fqn] = ['paramNames' => $marker['paramNames'], 'body' => $marker['body']]; + } + return $table; + } + /** * Walk the AST: attach markers to ClassLike and Name nodes by (line, name) + order; resolve TypeRef names. * @@ -2803,15 +2872,17 @@ private static function applyReplacements(string $source, array $replacements): * @param list}> $nameMarkers * @param list}> $methodMarkers * @param list $closureMarkers + * @param list, body:TypeRef, bytePosition:int}> $aliasMarkers */ - private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers, array $closureMarkers, ByteOffsetMap $byteOffsetMap): ?string + private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers, array $closureMarkers, ByteOffsetMap $byteOffsetMap, array $aliasMarkers): ?string { + $aliasTable = self::buildAliasTable($ast, $aliasMarkers, $byteOffsetMap); $traverser = new NodeTraverser(); $visitor = new /** * @phpstan-import-type BoundDict from XphpSourceParser */ - class($classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap) extends NodeVisitorAbstract { + class($classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap, $aliasTable) extends NodeVisitorAbstract { private NamespaceContext $ctx; /** @var list> stack of enclosing type-param scopes */ private array $typeParamStack = []; @@ -2826,6 +2897,8 @@ class($classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetM * @param array}> $nameMarkers * @param array}> $methodMarkers * @param array $closureMarkers + * @param array, body:TypeRef}> $aliasTable file-local + * type aliases keyed by FQN; body is the raw (unresolved) TypeRef. */ public function __construct( private array $classMarkers, @@ -2833,11 +2906,22 @@ public function __construct( private array $methodMarkers, private array $closureMarkers, private ByteOffsetMap $byteOffsetMap, + private array $aliasTable, ) { $this->ctx = new NamespaceContext(); } - public function enterNode(Node $node): null + /** + * Cache of resolved alias bodies keyed by alias FQN — the raw body is resolved once + * (against the use-site namespace context, with the alias's params in scope) and reused. + * + * @var array + */ + private array $aliasBodyCache = []; + + // Returns a replacement Node when a type-alias use is expanded in place (the traverser + // swaps it into the parent slot); null in every other case leaves the node untouched. + public function enterNode(Node $node): ?Node { if ($node instanceof Use_ || $node instanceof GroupUse) { // Reject a generic clause on a namespace-import BEFORE the blanket @@ -3162,6 +3246,16 @@ public function enterNode(Node $node): null break; } } + + // Alias expansion (WI-01): if this type-position Name resolves to a declared + // single-head alias, replace it with the recursively-expanded body so nothing + // downstream (registry, specializer, call-site rewriter) ever sees the alias. + // Runs after marker binding (a generic use's args are on the node by now) and + // after the parent slot's markName (a bare use's ATTR_RESOLVED_FQN is set). + $expansion = $this->expandAliasName($node); + if ($expansion !== null) { + return $expansion; + } } // Tag bare class/interface Name references in class-name positions @@ -3744,6 +3838,142 @@ private function resolveTypeRef(TypeRef $ref): TypeRef ); } + /** + * If this type-position Name resolves to a declared single-head alias, return the AST + * node for its fully-expanded body; otherwise null (leave the node untouched). The use's + * head + arguments come from the attributes already attached: a generic use carries + * ATTR_GENERIC_ARGS + ATTR_TEMPLATE_FQN, a bare use carries ATTR_RESOLVED_FQN. A Name in a + * non-type position (a plain function call) has neither and is skipped. + */ + private function expandAliasName(Name $node): ?Node + { + // @infection-ignore-all ReturnRemoval -- optimization only: with an empty table the + // `isset($this->aliasTable[$head])` guard below already returns null for every name. + if ($this->aliasTable === []) { + return null; + } + $genericArgs = $node->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + $templateFqn = $node->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); + $resolvedFqn = $node->getAttribute(XphpSourceParser::ATTR_RESOLVED_FQN); + // @infection-ignore-all LogicalAnd -- ATTR_GENERIC_ARGS and ATTR_TEMPLATE_FQN are + // attached together by the generic-marker binding (never one without the other), so + // `&&` and `||` select the same branch here. + if (is_array($genericArgs) && is_string($templateFqn)) { + /** @var list $genericArgs */ + $head = ltrim($templateFqn, '\\'); + $useArgs = $genericArgs; + } elseif (is_string($resolvedFqn)) { + $head = ltrim($resolvedFqn, '\\'); + $useArgs = []; + } else { + return null; + } + // Expand the head AND (recursively) the arguments — an alias can appear as a generic + // argument of a non-alias type (`Bag`), not just as the head. If nothing was an + // alias the expansion is identical to the input, so the node is left untouched. + $useRef = new TypeRef($head, $useArgs); + $expanded = $this->expandAlias($useRef, [], $node->getStartLine()); + if ($expanded->canonical() === $useRef->canonical()) { + return null; + } + // Drop the pre-expansion xphp attributes; typeRefToNode re-adds the right ones for + // the expanded head (position attributes are preserved so diagnostics still map back). + $attrs = $node->getAttributes(); + unset( + $attrs[XphpSourceParser::ATTR_GENERIC_ARGS], + $attrs[XphpSourceParser::ATTR_TEMPLATE_FQN], + $attrs[XphpSourceParser::ATTR_RESOLVED_FQN], + $attrs[XphpSourceParser::ATTR_SUSPECT_UNDECLARED_TYPE], + ); + return Specializer::typeRefToNode($expanded, $attrs); + } + + /** + * Recursively expand a type reference against the file-local alias table. A non-alias + * head is returned with its arguments expanded; an alias head is substituted with its + * body (params → arguments) and re-expanded, so nested and concrete-instantiation aliases + * (`type UserMap = Pair`) resolve fully. A head that recurs into itself is a + * cycle, and a use whose argument count differs from the alias's parameter count is an + * arity error — both fail loudly (refined into `xphp.alias_cycle` / `xphp.alias_arity` + * diagnostics in a later change). + * + * @param list $visited alias FQNs already entered on this expansion chain + */ + private function expandAlias(TypeRef $ref, array $visited, int $line): TypeRef + { + $expandedArgs = array_map(fn (TypeRef $a): TypeRef => $this->expandAlias($a, [], $line), $ref->args); + $entry = $this->aliasTable[$ref->name] ?? null; + if ($entry === null) { + return new TypeRef($ref->name, $expandedArgs, $ref->isScalar, $ref->isTypeParam, $ref->suspectUndeclared); + } + if (in_array($ref->name, $visited, true)) { + throw new XphpParseException( + "Type alias `{$ref->name}` is defined (directly or transitively) in terms of itself.", + $line, + ); + } + if (count($expandedArgs) !== count($entry['paramNames'])) { + throw new XphpParseException( + "Type alias `{$ref->name}` expects " . count($entry['paramNames']) + . ' type argument(s), ' . count($expandedArgs) . ' given.', + $line, + ); + } + $subst = []; + foreach ($entry['paramNames'] as $k => $paramName) { + $subst[$paramName] = $expandedArgs[$k]; + } + $substituted = self::substituteTypeRef($this->resolveAliasBody($ref->name, $entry), $subst); + return $this->expandAlias($substituted, [...$visited, $ref->name], $line); + } + + /** + * Resolve an alias's raw body against the current namespace context, with the alias's own + * type parameters pushed so `A` / `B` become type-param references rather than qualified + * class names. Cached per alias FQN. + * + * @param array{paramNames:list, body:TypeRef} $entry + */ + private function resolveAliasBody(string $fqn, array $entry): TypeRef + { + // @infection-ignore-all ReturnRemoval -- the cache is an optimization; resolveTypeRef + // is deterministic for a fixed context, so re-resolving on a cache miss is equivalent. + if (isset($this->aliasBodyCache[$fqn])) { + return $this->aliasBodyCache[$fqn]; + } + // Resolve the body with the alias's own parameters in scope, then restore the exact + // prior scope stack — so the alias's params never leak into later resolution. Restore + // by saved-copy assignment (not a pop) so the restore is exact and unconditional. + $saved = $this->typeParamStack; + $this->typeParamStack[] = $entry['paramNames']; + $resolved = $this->resolveTypeRef($entry['body']); + $this->typeParamStack = $saved; + return $this->aliasBodyCache[$fqn] = $resolved; + } + + /** + * Replace type-parameter leaves in a resolved TypeRef tree using a name → concrete map. + * + * @param array $subst + */ + private static function substituteTypeRef(TypeRef $ref, array $subst): TypeRef + { + // @infection-ignore-all LogicalAnd -- a resolved body's type-param leaves are exactly + // the alias's parameters, every one present in $subst; and a class leaf's FQN name + // never equals a bare parameter-name key. So both operands are always true together or + // false together, and `&&`/`||` select the same result. + if ($ref->isTypeParam && isset($subst[$ref->name])) { + return $subst[$ref->name]; + } + return new TypeRef( + $ref->name, + array_map(static fn (TypeRef $a): TypeRef => self::substituteTypeRef($a, $subst), $ref->args), + $ref->isScalar, + $ref->isTypeParam, + $ref->suspectUndeclared, + ); + } + /** * A bare, single-segment, non-imported class name used inside a generic * context — the suspect condition shared by the bound/default TypeRef path diff --git a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php new file mode 100644 index 00000000..a0958415 --- /dev/null +++ b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php @@ -0,0 +1,281 @@ +] = SingleHead;`, WI-01): a declared alias + * is a compile-time substitution — it is expanded into its body before specialization and has no + * runtime existence. Covers a generic alias, a non-generic (plain-class) alias, and a + * concrete-instantiation alias that references another alias; that the emitted program runs; that the + * alias name is absent from the output; and that a cyclic or arity-mismatched alias fails loudly. + */ +final class TypeAliasIntegrationTest extends TestCase +{ + private string $work; + + protected function setUp(): void + { + $this->work = sys_get_temp_dir() . '/xphp-alias-' . uniqid('', true); + mkdir($this->work, 0o755, true); + } + + protected function tearDown(): void + { + self::rrmdir($this->work); + } + + #[RunInSeparateProcess] + public function testTypeAliasesExpandAndRunAtRuntime(): void + { + // The non-negotiable gate: execute the emitted output. That the program runs and returns the + // right classes proves each alias expanded to its body and dispatched to real specializations. + $fixture = CompiledFixture::compile( + __DIR__ . '/../../fixture/compile/type_aliases/source', + 'aliases', + ); + try { + $fixture->registerAutoload('App\\Aliases'); + $runtime = require __DIR__ . '/../../fixture/compile/type_aliases/verify/runtime.php'; + $runtime($fixture); + } finally { + $fixture->cleanup(); + } + } + + public function testGenericAliasExpandsToItsBodySpecialization(): void + { + $use = self::read($this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => " = Dict>;\nfunction f(): Pair { return new Pair::(1, new Bag::(new User())); }\n", + ]), 'Use.php'); + + // Pair → Dict>: the emitted type is the Dict specialization… + self::assertStringContainsString('Generated\\App\\Dict\\T_', $use); + // …and the alias name is gone entirely (no `Pair`, no residual turbofish). + self::assertStringNotContainsString('Pair', $use); + self::assertStringNotContainsString('::<', $use); + } + + public function testNonGenericAliasExpandsToItsTargetClass(): void + { + $use = self::read($this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => "compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => " = Dict>;\ntype UserMap = Pair;\nfunction f(): UserMap { return new UserMap(1, new Bag::(new User())); }\n", + ]), 'Use.php'); + + // UserMap → Pair → Dict>: fully expanded, no alias name remains. + self::assertStringContainsString('Generated\\App\\Dict\\T_', $use); + self::assertStringNotContainsString('UserMap', $use); + self::assertStringNotContainsString('Pair', $use); + } + + public function testAliasInGenericArgumentPositionExpands(): void + { + // An alias used as a generic ARGUMENT of a non-alias type (`Bag`) must expand too — + // expansion recurses into arguments, not just the head. + $use = self::read($this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => " { return new Bag::(new User()); }\n", + ]), 'Use.php'); + + // Bag → Bag: the Bag specialization holds User; no `Elem` remains. + self::assertStringContainsString('Generated\\App\\Bag\\T_', $use); + self::assertStringNotContainsString('Elem', $use); + } + + public function testNonAliasTypeInAnAliasFileIsLeftUnchanged(): void + { + // The alias table is consulted for every type-position name, but a non-alias class type must + // pass through byte-for-byte — expansion rebuilds a node only when something actually changed. + $use = self::read($this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => " { return new Bag::(\$u); }\n", + ]), 'Use.php'); + + // `User` (a real class, not an alias) is emitted exactly as written — not rewritten/qualified. + self::assertStringContainsString('function k(User $u)', $use); + } + + public function testAliasIsKeyedByItsDeclaringNamespace(): void + { + // An alias declared in the SECOND namespace must key under that namespace — the byte-span + // attribution must not fall through to an earlier namespace (the `&&` containment check). + $out = self::read($this->compile([ + 'Multi.xphp' => "` is resolved first, then `Second = A` must resolve `A` + // to the class \App\A, not to a leaked type parameter. + $use = self::read($this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => " = Bag;\ntype Second = A;\nfunction useFirst(): First { return new Bag::(1); }\nfunction useSecond(): Second { return new A(); }\n", + ]), 'Use.php'); + + // Second → A resolves to the class \App\A (a leaked type param would emit a bare `\A`). + self::assertStringContainsString('App\\A', $use); + } + + public function testAliasInGlobalNamespaceBlock(): void + { + // A `namespace { ... }` block has no name; the alias keys under the global namespace. + $out = self::read($this->compile([ + 'G.xphp' => " " = B;\ntype B = A;\nclass Box { public function __construct(public T \$v) {} }\nfunction f(): A { return new Box::(1); }\n", + ]; + self::assertRejected($this->check($files), 'in terms of itself'); + $this->assertCompileThrows($files, 'in terms of itself'); + } + + public function testAliasArityMismatchIsRejectedInBothModes(): void + { + $files = [ + 'C.xphp' => " = Dict;\nclass Dict { public function __construct(public K \$k, public V \$v) {} }\nfunction f(): P { return new Dict::(1, 2); }\n", + ]; + self::assertRejected($this->check($files), 'expects 2 type argument(s), 1 given'); + $this->assertCompileThrows($files, 'expects 2 type argument(s), 1 given'); + } + + private static function assertRejected(DiagnosticCollector $collector, string $needle): void + { + self::assertTrue($collector->hasErrors(), 'check must collect the alias rejection, not silently pass'); + $messages = array_map(static fn ($d): string => $d->message, $collector->all()); + self::assertStringContainsString($needle, implode("\n", $messages)); + } + + /** @param array $files */ + private function assertCompileThrows(array $files, string $needle): void + { + try { + $this->compile($files); + self::fail('compile must reject the alias loudly'); + } catch (XphpParseException $e) { + self::assertStringContainsString($needle, $e->getMessage()); + } + } + + private const LIB = <<<'PHP' + { public function __construct(public T $item) {} public function get(): T { return $this->item; } } + class Dict { public function __construct(public K $key, public V $value) {} public function value(): V { return $this->value; } } + PHP; + + // --- helpers (kept local, matching the other Monomorphize integration tests) --------------- + + /** @param array $files */ + private function compile(array $files): string + { + $src = $this->writeSources($files); + $dist = $src . '/dist'; + $this->newCompiler()->compile($this->sourcesIn($src), $src, $dist, $src . '/.xphp-cache'); + return $dist; + } + + /** @param array $files */ + private function check(array $files): DiagnosticCollector + { + $src = $this->writeSources($files); + return $this->newCompiler()->check($this->sourcesIn($src)); + } + + /** @param array $files */ + private function writeSources(array $files): string + { + $src = $this->work . '/' . uniqid('src', true); + mkdir($src, 0o755, true); + foreach ($files as $name => $contents) { + file_put_contents($src . '/' . $name, $contents); + } + return $src; + } + + private function sourcesIn(string $src): \XPHP\FileSystem\FilepathArray + { + return (new NativeFileFinder())->find($src) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + } + + private function newCompiler(): Compiler + { + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + return new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser((new ParserFactory())->createForHostVersion()), + 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/type_aliases/source/Types.xphp b/test/fixture/compile/type_aliases/source/Types.xphp new file mode 100644 index 00000000..bc73db48 --- /dev/null +++ b/test/fixture/compile/type_aliases/source/Types.xphp @@ -0,0 +1,51 @@ + = Dict>; +type UserId = Ident; +type UserMap = Pair; +type Elem = User; // used only as a generic ARGUMENT (`Bag`) + +class Ident {} +class User {} + +class Bag +{ + public function __construct(public T $item) {} + public function get(): T { return $this->item; } +} + +class Dict +{ + public function __construct(public K $key, public V $value) {} + public function value(): V { return $this->value; } +} + +class Service +{ + // Alias uses in return-type position (generic + non-generic) must expand before specialization. + public function pair(): Pair + { + return new Pair::(1, new Bag::(new User())); + } + + public function id(): UserId + { + return new UserId(); + } +} + +// Driver: the runtime verify reads these top-level values after requiring the emitted file. +$service = new Service(); +$pair = $service->pair(); +$idValue = $service->id(); +$userMap = new UserMap(2, new Bag::(new User())); +// `Bag` — an alias in generic-argument position. If it did not expand to `Bag`, the +// generated specialization would be typed on the nonexistent class `App\Aliases\Elem` and fatal here. +$elemBag = new Bag::(new User()); diff --git a/test/fixture/compile/type_aliases/verify/runtime.php b/test/fixture/compile/type_aliases/verify/runtime.php new file mode 100644 index 00000000..acd1106f --- /dev/null +++ b/test/fixture/compile/type_aliases/verify/runtime.php @@ -0,0 +1,38 @@ + = Dict>`), a non-generic + * plain-class alias (`UserId = Ident`), and a concrete-instantiation alias that references another + * alias (`UserMap = Pair`) are all erased before specialization, and the emitted program + * executes end to end — the alias uses dispatch to the same specializations the hand-expanded types + * would, and the plain alias resolves to its target class. + * + * Driver contract: the driver invokes the returned closure with the `CompiledFixture`. The user + * files aren't PSR-4, so require them in dependency order; the generated specializations autoload. + */ + +use PHPUnit\Framework\Assert; +use XPHP\TestSupport\CompiledFixture; + +return function (CompiledFixture $fixture): void { + require $fixture->targetDir . '/Types.php'; + + // Pair === Dict>: the value is a Bag specialization holding a User. + Assert::assertInstanceOf('App\\Aliases\\User', $pair->value()->get(), 'Pair expanded to Dict>'); + + // UserId === Ident (a plain class): the alias resolves to its target class. + Assert::assertInstanceOf('App\\Aliases\\Ident', $idValue, 'UserId expanded to the plain class Ident'); + + // UserMap === Pair === Dict> (nested alias): same shape as $pair. + Assert::assertInstanceOf('App\\Aliases\\User', $userMap->value()->get(), 'UserMap expanded through Pair to Dict>'); + Assert::assertSame( + $pair::class, + $userMap::class, + 'UserMap and Pair expand to the identical specialization', + ); + + // Bag === Bag: an alias in generic-argument position expanded; the item is a User. + Assert::assertInstanceOf('App\\Aliases\\User', $elemBag->get(), 'Bag expanded to Bag'); +}; From 585d933e27b12e46fc79641543258192bde07f25 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 30 Jul 2026 17:38:57 +0000 Subject: [PATCH 31/46] feat(monomorphize): dedicated diagnostics for type-alias rejections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give each type-alias rejection a stable code and raise the two that were previously silent or unclear: - xphp.alias_class_collision — an alias whose FQN matches a class / interface / trait declared in the same file is now a loud error, not a silent shadow of that class. - xphp.alias_unsupported_body — a union / intersection / nullable / closure body is recognized and stripped (so `strip()` never emits a raw PHP parse error) and rejected with a clear message at parse time. - xphp.alias_duplicate — the same alias FQN declared twice in a file. - xphp.alias_cycle / xphp.alias_arity — promoted from the generic parse-error code to dedicated codes. XphpParseException carries an optional diagnostic code; check maps it onto the collected diagnostic (compile still throws). Every rejection is verified in both modes. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/Compiler.php | 5 +- .../Monomorphize/XphpParseException.php | 17 ++- .../Monomorphize/XphpSourceParser.php | 122 +++++++++++++++--- .../Monomorphize/TypeAliasIntegrationTest.php | 65 +++++++++- .../Monomorphize/XphpSourceParserTest.php | 20 +-- 5 files changed, 196 insertions(+), 33 deletions(-) diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 23e8e60f..79a63b56 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -478,11 +478,12 @@ public function check(FilepathArray $sources): DiagnosticCollector } catch (XphpParseException $e) { // xphp-specific parse-time rejections from the scanner (e.g. variance markers // on methods, malformed generic defaults) — these carry the offending token's - // original-source line so the diagnostic points at the real site. + // original-source line so the diagnostic points at the real site, and optionally a + // stable diagnostic code (e.g. a type-alias rejection) in place of the generic one. $line = $e->sourceLine(); $diagnostics->add(new Diagnostic( Severity::Error, - self::CODE_PARSE_ERROR, + $e->diagnosticCode() ?? self::CODE_PARSE_ERROR, $e->getMessage(), // @infection-ignore-all GreaterThan/IncrementInteger/DecrementInteger -- every // current throw site supplies a real token line (>= 1), so this `> 0` guard is diff --git a/src/Transpiler/Monomorphize/XphpParseException.php b/src/Transpiler/Monomorphize/XphpParseException.php index e22152ea..b743d0fe 100644 --- a/src/Transpiler/Monomorphize/XphpParseException.php +++ b/src/Transpiler/Monomorphize/XphpParseException.php @@ -14,11 +14,18 @@ * `RuntimeException` keep catching it unchanged — only the line is added. Check * mode catches it specifically to report the real line in its diagnostic instead * of the line-1 fallback used for position-less parse failures. + * + * An optional stable diagnostic `code` (e.g. `xphp.alias_cycle`) lets check mode + * report a specific code instead of the generic parse-error code; throw sites that + * omit it keep the generic code. */ final class XphpParseException extends RuntimeException { - public function __construct(string $message, private readonly int $sourceLine) - { + public function __construct( + string $message, + private readonly int $sourceLine, + private readonly ?string $diagnosticCode = null, + ) { parent::__construct($message); } @@ -30,4 +37,10 @@ public function sourceLine(): int { return $this->sourceLine; } + + /** The stable diagnostic code for this rejection, or null to use the generic parse-error code. */ + public function diagnosticCode(): ?string + { + return $this->diagnosticCode; + } } diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 81cc5ef1..92239d5a 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -110,6 +110,13 @@ final class XphpSourceParser // tagged (the escape hatch). Advisory metadata only — not emitted. public const ATTR_SUSPECT_UNDECLARED_TYPE = 'xphp:suspectUndeclaredType'; + /** Stable diagnostic codes for type-alias (WI-01) rejections. */ + public const CODE_ALIAS_CYCLE = 'xphp.alias_cycle'; + public const CODE_ALIAS_ARITY = 'xphp.alias_arity'; + public const CODE_ALIAS_DUPLICATE = 'xphp.alias_duplicate'; + public const CODE_ALIAS_CLASS_COLLISION = 'xphp.alias_class_collision'; + public const CODE_ALIAS_UNSUPPORTED_BODY = 'xphp.alias_unsupported_body'; + /** * The reserved PHP type keywords — names PHP forbids as class names. A bare name in this list is * unambiguously a builtin, so every site that asks "is this name a builtin keyword or a class?" @@ -307,7 +314,7 @@ public function strip(string $source): string } /** - * @return array{0: list}>, 1: list}>, 2: list}>, 3: string, 4: ByteOffsetMap, 5: list, 6: list, body:TypeRef, bytePosition:int}>} + * @return array{0: list}>, 1: list}>, 2: list}>, 3: string, 4: ByteOffsetMap, 5: list, 6: list, body:?TypeRef, bytePosition:int, line:int}>} */ private function scanAndStrip(string $source): array { @@ -321,7 +328,7 @@ private function scanAndStrip(string $source): array $methodMarkers = []; /** @var list $closureMarkers */ $closureMarkers = []; - /** @var list, body:TypeRef, bytePosition:int}> $aliasMarkers */ + /** @var list, body:?TypeRef, bytePosition:int, line:int}> $aliasMarkers */ $aliasMarkers = []; /** @var list $replacements [byte offset, original length, replacement text] */ $replacements = []; @@ -2427,7 +2434,7 @@ private static function parseTypeArgList(array $tokens, int $openIdx): ?array * a member (`Foo::type`, `$x->type`, `new type()`) is never mistaken for a declaration. * * @param list $tokens - * @return array{0: array{name:string, paramNames:list, body:TypeRef, bytePosition:int}, 1: int}|null + * @return array{0: array{name:string, paramNames:list, body:?TypeRef, bytePosition:int, line:int}, 1: int}|null */ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ?array { @@ -2483,20 +2490,24 @@ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ? return null; } - // Single (possibly-generic) head body. A union / intersection / nullable / closure body - // leaves a non-`;` token after the head and is declined for v1 (a later change turns that - // into an explicit `xphp.alias_unsupported_body` diagnostic rather than a silent decline). - $bodyParsed = self::parseTypeArg($tokens, self::skipWs($tokens, $eqIdx + 1)); - if ($bodyParsed === null) { + // The alias statement must be terminated by a `;` before any `{` / `}` / end of input, + // otherwise it is truncated (mid-typing) and we decline so the tolerant path and PHP's own + // parser handle it. + $bodyStart = self::skipWs($tokens, $eqIdx + 1); + $semiIdx = self::aliasTerminator($tokens, $bodyStart); + if ($semiIdx === null) { return null; } - [$body, $afterBody] = $bodyParsed; - // Terminating `;` (a single-head body leaves it immediately after the head). - $semiIdx = self::skipWs($tokens, $afterBody); - if (($tokens[$semiIdx] ?? null)?->text !== ';') { - return null; - } + // A single (possibly-generic) head immediately followed by that `;` is a supported body. + // Anything else (union / intersection / nullable / closure) is recorded with a null body: + // the whole statement is still stripped here (so `strip()` never produces a PHP parse error), + // and `buildAliasTable` rejects the null body with a clear `xphp.alias_unsupported_body` + // diagnostic at parse time. + $bodyParsed = self::parseTypeArg($tokens, $bodyStart); + $body = ($bodyParsed !== null && self::skipWs($tokens, $bodyParsed[1]) === $semiIdx) + ? $bodyParsed[0] + : null; return [ [ @@ -2504,11 +2515,33 @@ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ? 'paramNames' => $paramNames, 'body' => $body, 'bytePosition' => $tokens[$typeIdx]->pos, + 'line' => $tokens[$typeIdx]->line, ], $semiIdx, ]; } + /** + * The index of the `;` that terminates an alias statement whose body starts at $bodyStart, or + * null when a `{` / `}` / end of input is reached first (a truncated, mid-typing declaration). + * A type body never contains `;` / `{` / `}`, so the first such token decides. + * + * @param list $tokens + */ + private static function aliasTerminator(array $tokens, int $bodyStart): ?int + { + for ($i = $bodyStart, $n = count($tokens); $i < $n; $i++) { + $text = $tokens[$i]->text; + if ($text === ';') { + return $i; + } + if ($text === '{' || $text === '}') { + return null; + } + } + return null; + } + /** * Parse a single type arg: `NAME ( < TypeArgList > )?`. * @@ -2809,7 +2842,7 @@ private static function applyReplacements(string $source, array $replacements): * duplicate-alias diagnostic lands in a later change). * * @param list $ast - * @param list, body:TypeRef, bytePosition:int}> $aliasMarkers + * @param list, body:?TypeRef, bytePosition:int, line:int}> $aliasMarkers * @return array, body:TypeRef}> */ private static function buildAliasTable(array $ast, array $aliasMarkers, ByteOffsetMap $byteOffsetMap): array @@ -2831,6 +2864,7 @@ private static function buildAliasTable(array $ast, array $aliasMarkers, ByteOff ]; } } + $classFqns = self::collectClassLikeFqns($ast); $table = []; foreach ($aliasMarkers as $marker) { $namespace = ''; @@ -2847,11 +2881,65 @@ private static function buildAliasTable(array $ast, array $aliasMarkers, ByteOff } } $fqn = $namespace === '' ? $marker['name'] : $namespace . '\\' . $marker['name']; + if ($marker['body'] === null) { + throw new XphpParseException( + "Type alias `{$fqn}` has an unsupported body: an alias body must be a single class " + . 'or generic type (unions, intersections, nullables, and closure signatures are ' + . 'not supported). Use a bare type or a named class.', + $marker['line'], + self::CODE_ALIAS_UNSUPPORTED_BODY, + ); + } + if (isset($table[$fqn])) { + throw new XphpParseException( + "Type alias `{$fqn}` is declared more than once in this file.", + $marker['line'], + self::CODE_ALIAS_DUPLICATE, + ); + } + if (isset($classFqns[$fqn])) { + throw new XphpParseException( + "Type alias `{$fqn}` collides with a class, interface, or trait of the same name.", + $marker['line'], + self::CODE_ALIAS_CLASS_COLLISION, + ); + } $table[$fqn] = ['paramNames' => $marker['paramNames'], 'body' => $marker['body']]; } return $table; } + /** + * Collect the fully-qualified names of every class / interface / trait / enum declared in the + * file, so a type alias colliding with one can be rejected. Declarations are direct children of a + * namespace (or top-level in the global namespace); this matches the file-local (v1) scope — a + * collision with a class declared in another file is not detected here. + * + * @param list $ast + * @return array + */ + private static function collectClassLikeFqns(array $ast): array + { + $fqns = []; + foreach ($ast as $stmt) { + if ($stmt instanceof Namespace_) { + $ns = $stmt->name?->toString() ?? ''; + foreach ($stmt->stmts as $inner) { + if ($inner instanceof ClassLike && $inner->name !== null) { + $short = $inner->name->toString(); + // @infection-ignore-all TrueValue -- a set membership; the value is only ever + // probed with isset(), which is true for any present key (incl. false). + $fqns[$ns === '' ? $short : $ns . '\\' . $short] = true; + } + } + } elseif ($stmt instanceof ClassLike && $stmt->name !== null) { + // @infection-ignore-all TrueValue -- set membership probed only with isset() (above). + $fqns[$stmt->name->toString()] = true; + } + } + return $fqns; + } + /** * Walk the AST: attach markers to ClassLike and Name nodes by (line, name) + order; resolve TypeRef names. * @@ -2872,7 +2960,7 @@ private static function buildAliasTable(array $ast, array $aliasMarkers, ByteOff * @param list}> $nameMarkers * @param list}> $methodMarkers * @param list $closureMarkers - * @param list, body:TypeRef, bytePosition:int}> $aliasMarkers + * @param list, body:?TypeRef, bytePosition:int, line:int}> $aliasMarkers */ private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers, array $closureMarkers, ByteOffsetMap $byteOffsetMap, array $aliasMarkers): ?string { @@ -3910,6 +3998,7 @@ private function expandAlias(TypeRef $ref, array $visited, int $line): TypeRef throw new XphpParseException( "Type alias `{$ref->name}` is defined (directly or transitively) in terms of itself.", $line, + XphpSourceParser::CODE_ALIAS_CYCLE, ); } if (count($expandedArgs) !== count($entry['paramNames'])) { @@ -3917,6 +4006,7 @@ private function expandAlias(TypeRef $ref, array $visited, int $line): TypeRef "Type alias `{$ref->name}` expects " . count($entry['paramNames']) . ' type argument(s), ' . count($expandedArgs) . ' given.', $line, + XphpSourceParser::CODE_ALIAS_ARITY, ); } $subst = []; diff --git a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php index a0958415..638913f4 100644 --- a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php +++ b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php @@ -168,7 +168,7 @@ public function testCyclicAliasIsRejectedInBothModes(): void $files = [ 'C.xphp' => " = B;\ntype B = A;\nclass Box { public function __construct(public T \$v) {} }\nfunction f(): A { return new Box::(1); }\n", ]; - self::assertRejected($this->check($files), 'in terms of itself'); + self::assertRejected($this->check($files), XphpSourceParser::CODE_ALIAS_CYCLE, 'in terms of itself'); $this->assertCompileThrows($files, 'in terms of itself'); } @@ -177,13 +177,72 @@ public function testAliasArityMismatchIsRejectedInBothModes(): void $files = [ 'C.xphp' => " = Dict;\nclass Dict { public function __construct(public K \$k, public V \$v) {} }\nfunction f(): P { return new Dict::(1, 2); }\n", ]; - self::assertRejected($this->check($files), 'expects 2 type argument(s), 1 given'); + self::assertRejected($this->check($files), XphpSourceParser::CODE_ALIAS_ARITY, 'expects 2 type argument(s), 1 given'); $this->assertCompileThrows($files, 'expects 2 type argument(s), 1 given'); } - private static function assertRejected(DiagnosticCollector $collector, string $needle): void + public function testUnsupportedAliasBodyIsRejectedInBothModes(): void + { + // A union / nullable / intersection / closure body is recognized (stripped) but rejected with + // a clear diagnostic — not a raw PHP parse error. The full message is asserted so a reworded + // or truncated diagnostic is caught. + $files = [ + 'C.xphp' => "check($files), XphpSourceParser::CODE_ALIAS_UNSUPPORTED_BODY, $message); + $this->assertCompileThrows($files, $message); + } + + public function testNoSpaceAliasBodyExpands(): void + { + // `type Id=Ident;` (no spaces around `=`) is a valid single-head alias, not an unsupported + // body — the body-start scan must land on `Ident`, not the `;`. + $use = self::read($this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => " "check($namespaced), XphpSourceParser::CODE_ALIAS_CLASS_COLLISION, 'collides with a class'); + $this->assertCompileThrows($namespaced, 'collides with a class'); + + // Global namespace (no `namespace` statement): the top-level class-declaration branch. + $global = [ + 'G.xphp' => "check($global), XphpSourceParser::CODE_ALIAS_CLASS_COLLISION, 'collides with a class'); + $this->assertCompileThrows($global, 'collides with a class'); + } + + public function testDuplicateAliasIsRejectedInBothModes(): void + { + $files = [ + 'C.xphp' => "check($files), XphpSourceParser::CODE_ALIAS_DUPLICATE, 'declared more than once'); + $this->assertCompileThrows($files, 'declared more than once'); + } + + private static function assertRejected(DiagnosticCollector $collector, string $code, string $needle): void { self::assertTrue($collector->hasErrors(), 'check must collect the alias rejection, not silently pass'); + $codes = array_map(static fn ($d): string => $d->code, $collector->all()); + self::assertContains($code, $codes, 'check must report the dedicated alias diagnostic code'); $messages = array_map(static fn ($d): string => $d->message, $collector->all()); self::assertStringContainsString($needle, implode("\n", $messages)); } diff --git a/test/Transpiler/Monomorphize/XphpSourceParserTest.php b/test/Transpiler/Monomorphize/XphpSourceParserTest.php index a0ed6357..dd919498 100644 --- a/test/Transpiler/Monomorphize/XphpSourceParserTest.php +++ b/test/Transpiler/Monomorphize/XphpSourceParserTest.php @@ -186,22 +186,22 @@ public function testTypeOutsideStatementPositionIsNeverAnAliasDeclaration(): voi } /** - * v1 recognizes only a single (possibly-generic) head body. A union / intersection / nullable - * body is declined at scan (left intact), to become an explicit diagnostic in a later change — - * it must never be half-stripped. + * A union / nullable body is a RECOGNIZED (but unsupported) alias: the whole statement is + * stripped at scan (so `strip()` never produces a raw PHP parse error), and the + * `xphp.alias_unsupported_body` diagnostic is raised later at parse time (see the integration + * test). A reserved-word head is not a recognized alias at all and is left byte-for-byte intact. */ - public function testNonSingleHeadAliasBodiesAreDeclined(): void + public function testUnsupportedAliasBodyIsStrippedWhileReservedNameIsDeclined(): void { $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); - // Union / nullable bodies: parseTypeArg stops at `|` / returns null on `?`, leaving a - // non-`;` token after the head, so the declaration is declined and left byte-for-byte intact. $union = "strip($union)); + self::assertSame(self::withBlanked($union, 'type Num = int|float;'), $parser->strip($union)); $nullable = "strip($nullable)); - // The alias name must be a real identifier (T_STRING). A reserved word like `array` - // (T_ARRAY) is not a valid alias head, so the declaration is declined and left intact. + self::assertSame(self::withBlanked($nullable, 'type Maybe = ?Box;'), $parser->strip($nullable)); + + // A reserved word (`array`, T_ARRAY) is not a valid alias head, so the declaration is not + // recognized and is left byte-for-byte intact. $reserved = "strip($reserved)); } From 729e16c432dd367dffba24c7eccd70ed125dee71 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 30 Jul 2026 17:49:23 +0000 Subject: [PATCH 32/46] docs(type-aliases): document the feature, ADR-0023, roadmap, changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New syntax tour page (docs/syntax/type-aliases.md) + index row. - Caveat covering the v1 boundaries (file-local, single-head bodies, same-file collision detection) and their reasons. - Roadmap: move type aliases from Discovery to Shipped. - ADR-0023: the declaration-form syntax decision (`type Name<…> = Body`) and compile-time-substitution model, with the alternatives weighed. - CHANGELOG entry under [Unreleased]. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 12 ++ .../adr/0023-type-alias-declaration-syntax.md | 113 ++++++++++++++++++ docs/adr/README.md | 1 + docs/caveats.md | 48 ++++++++ docs/roadmap.md | 19 ++- docs/syntax/index.md | 1 + docs/syntax/type-aliases.md | 105 ++++++++++++++++ 7 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 docs/adr/0023-type-alias-declaration-syntax.md create mode 100644 docs/syntax/type-aliases.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b6d67b20..8546cb54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Type aliases.** `type Name = Body;` (generic) and `type Name = Body;` + (non-generic) give a type a reusable name. An alias is a compile-time + substitution — expanded into its body before specialization, with no runtime + existence, so the emitted PHP never mentions the alias. It expands in every type + position, including as a generic argument (`Bag`), and composes with + nested and concrete-instantiation aliases (`type UserMap = Pair`). + v1 is file-local with single-head bodies; a cyclic (`xphp.alias_cycle`), + arity-mismatched (`xphp.alias_arity`), class-colliding + (`xphp.alias_class_collision`), duplicate (`xphp.alias_duplicate`), or + unsupported-body (`xphp.alias_unsupported_body`, e.g. a union/nullable/closure + body) alias is a loud error in both `xphp compile` and `xphp check`. See + [type aliases](docs/syntax/type-aliases.md). - **Type-argument inference (optional turbofish).** A generic call or `new` whose type parameters are determined by the argument values no longer needs the `::<>` turbofish: `identity(5)` infers `identity::`, `new Box($product)` infers diff --git a/docs/adr/0023-type-alias-declaration-syntax.md b/docs/adr/0023-type-alias-declaration-syntax.md new file mode 100644 index 00000000..83879fb7 --- /dev/null +++ b/docs/adr/0023-type-alias-declaration-syntax.md @@ -0,0 +1,113 @@ +# 23. Type-alias syntax is the declaration form `type Name<…> = Body` + +- Status: Accepted — 2026-07 + +## Context and Problem Statement + +xphp adds type aliases — a name for a type, expanded at compile time (see +[type aliases](../syntax/type-aliases.md)). A first-class goal is that an alias may be +**generic** (`type Pair = Map>`), not only a name for a fixed type. + +PHP itself has a live but unsettled proposal, [PHP RFC: Type +Aliases](https://wiki.php.net/rfc/typed-aliases), which uses an *import* form +(`use type int|float as Number;`) and explicitly lists parameterized (generic) aliases +under "Future Scope" — so there is no PHP-blessed syntax for the generic case xphp needs. +xphp must therefore choose a surface, ideally one that stays forward-compatible with where +PHP is most likely to land. + +## Decision Drivers + +- **Must express generic aliases**, since that is a primary goal. +- Forward-compatibility with a plausible future PHP syntax. +- Fit xphp's existing angle-bracket surface (`Foo`, the `::<>` turbofish). +- Correctness first: no silent miscompile; an alias must lower to exactly what its body + would have. + +## Considered Options + +- **A — declaration form `type Name<…> = Body;`** (with the non-generic case being the + zero-parameter `type Name = Body;`). The form used by TypeScript, Rust, Scala, and — most + relevantly — **Hack**, PHP's closest relative. +- **B — import form `use type Body as Name;`** (PHP's current RFC). +- **C — a distinct keyword** (`typedef` / `typealias`). +- **D — a runtime, autoloadable alias symbol** (an alias that exists at runtime and via + reflection), rather than a pure compile-time substitution. + +## Decision Outcome + +Chosen: **A — the declaration form `type Name<…> = Body`, resolved as a compile-time +substitution.** + +The import form (B) is eliminated by the generic requirement: `use type Body as Name` has +no place to put parameters on `Name` (`use type Map> as Pair` is +ambiguous), which is almost certainly why PHP deferred generic aliases. The declaration +form is the *only* one of the two that expresses both cases with a single rule, and it is +what every language that supports generic aliases uses. Hack — the closest precedent to +xphp's situation — spells it exactly `type Name = …;`. It also fits xphp's own +angle-bracket surface. A distinct keyword (C) buys nothing over `type` and is further from +that precedent. + +Aliases are a **compile-time substitution** with no runtime existence (not option D). The +long-standing blocker for PHP here — how to autoload/define a runtime alias symbol — simply +does not arise for xphp: it is a whole-program, build-time transpiler +([ADR-0002](0002-build-time-transpiler.md)), so an alias is expanded before specialization +and needs no runtime identity. + +### Consequences + +- Good: one grammar covers generic and non-generic aliases; it matches the cross-language + and Hack consensus and xphp's existing syntax; expansion reuses the monomorphizer with no + new emission path or runtime cost. +- Trade-off: for the *generic* case xphp defines surface ahead of PHP (which deferred it), + a bet on the declaration-form consensus. The non-generic import form (`use type … as`) + could be added later as a parity synonym without disturbing this decision. +- Trade-off: v1 is scoped to file-local, single-head bodies (see the + [caveat](../caveats.md#type-aliases-are-file-local-and-single-head)) — a safe subset, + with cross-file and richer bodies as later work. + +### Confirmation + +The scanner recognizes `type Name[<…>] = SingleHead;` and strips it; expansion is exercised +end to end by `test/fixture/compile/type_aliases/` (a runtime fixture that executes the +compiled output and asserts no alias name survives) and the `TypeAliasIntegrationTest` +cases. Every rejection carries a stable code (`xphp.alias_cycle`, `xphp.alias_arity`, +`xphp.alias_class_collision`, `xphp.alias_duplicate`, `xphp.alias_unsupported_body`) and is +verified in both `compile` and `check`. + +## Pros and Cons of the Options + +### A — declaration form `type Name<…> = Body` + +- Good: expresses generic and non-generic aliases with one rule; matches Hack + TS + Rust + + Scala; fits xphp's angle-bracket surface. +- Bad: leads PHP for the generic case (PHP has only the import form, and only for + non-generic aliases so far). + +### B — import form `use type Body as Name` + +- Good: matches PHP's current RFC for the non-generic case; forward-compatible there. +- Bad: cannot carry type parameters, so it cannot express generic aliases — the primary + goal. + +### C — distinct keyword (`typedef` / `typealias`) + +- Good: unambiguous keyword. +- Bad: no advantage over `type`; further from the Hack precedent and the cross-language norm. + +### D — runtime / autoloadable alias symbol + +- Good: reflection and cross-file use "for free". +- Bad: imports PHP's unsolved autoloading/definition problem for no benefit — xphp expands + aliases at build time and needs no runtime symbol. + +## More Information + +- [Type aliases](../syntax/type-aliases.md) and the + [file-local / single-head caveat](../caveats.md#type-aliases-are-file-local-and-single-head). +- [ADR-0001](0001-monomorphization-over-type-erasure.md) — monomorphization; + [ADR-0002](0002-build-time-transpiler.md) — build-time transpiler (why a runtime alias + symbol is unnecessary). +- [PHP RFC: Type Aliases](https://wiki.php.net/rfc/typed-aliases) (import form; generic + aliases in Future Scope); [PHP RFC: Bound-erased generic + types](https://wiki.php.net/rfc/bound_erased_generic_types) (the `Foo` surface xphp + tracks). Hack spells the declaration form `type Name = …;` (and `newtype`). diff --git a/docs/adr/README.md b/docs/adr/README.md index c0d5bcdb..b4216ff8 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -40,3 +40,4 @@ should be added here as a new numbered file; copy | [0020](0020-diagnose-and-restructure-self-reintroducing-specialization.md) | Diagnose and restructure self-reintroducing specialization (erased seam deferred) | Accepted | | [0021](0021-compile-runs-the-check-gate-by-default.md) | `xphp compile` runs the check gate by default | Accepted | | [0022](0022-bounds-are-upper-only.md) | Bounds are upper-only (no supertype/lower bounds) | Accepted | +| [0023](0023-type-alias-declaration-syntax.md) | Type-alias syntax is the declaration form `type Name<…> = Body` | Accepted | diff --git a/docs/caveats.md b/docs/caveats.md index a261238e..560b707b 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -89,6 +89,54 @@ wherever inference can't see the type. It's always accepted, and an inferred call is identical to the turbofished one — so adding a turbofish never changes behavior, only makes the type explicit. +## Type aliases are file-local and single-head + +[Type aliases](syntax/type-aliases.md) (`type Name<…> = Body;`) are a compile-time +substitution — a deliberately small first step, with three boundaries. + +### ❌ What doesn't work + +```php +// File Types.xphp +type UserId = Ident; + +// File Other.xphp — a DIFFERENT file +function f(): UserId { /* ... */ } // ✗ UserId is not visible here (file-local) + +type Num = int|string; // ✗ xphp.alias_unsupported_body — union body +type Maybe = ?Box; // ✗ xphp.alias_unsupported_body — nullable body +type Fn = Closure(int): int; // ✗ xphp.alias_unsupported_body — closure signature +``` + +An alias colliding with a class in **another** file is also not detected (a +same-file collision is — `xphp.alias_class_collision`). + +### Why + +An alias is expanded before specialization, during the per-file parse: it has no +runtime existence, and the parse has no cross-file symbol table, so an alias is +scoped to the file (and namespace) that declares it. The body is restricted to a +single class or generic *head* because that is the shape the monomorphizer can +substitute directly into a type position; a union / intersection / nullable / +closure body has no single identity to carry through specialization, so it is +rejected loudly rather than mis-compiled. Both boundaries are the same "make the +safe subset solid first" trade the rest of xphp makes — they are candidates to +lift later, not permanent design limits. + +### ✅ Workaround + +- Keep an alias and its uses in the **same file**. For a shared vocabulary, + declare the alias in each file that needs it (it's a zero-cost substitution). +- For a non-single-head type, write the type directly, or wrap it in a named + class or interface and alias *that*: + +```php +type UserId = int|string; // ✗ rejected +interface UserId { /* marker */ } // ✓ a named type you can alias/reference +``` + +--- + ## `$this`-capturing arrows and closures rejected ### ❌ What doesn't work diff --git a/docs/roadmap.md b/docs/roadmap.md index b5e32956..6fcd6cbd 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -50,6 +50,9 @@ timeline Reified T : runtime instanceof T : marker interface per template + Type aliases + : compile-time substitution + : file-local single-head bodies Developer experience : RFC-aligned call-site syntax : empty turbofish for all-defaults templates @@ -61,7 +64,6 @@ timeline : PHPStan over the compiled output section Discovery Generic surface - : Generic type aliases : Variance edges on trait-owned templates : Branching narrowing precision Generic completeness @@ -219,6 +221,20 @@ upcoming one. - Marker interface per template so `$x instanceof App\Box` works across every `Box<...>` specialization. +### Type aliases + +- `type Name = Body;` and `type Name = Body;` — a compile-time + substitution expanded into its body before specialization, with no + runtime existence (the emitted PHP never mentions the alias). +- Expands in every type position, including as a generic argument + (`Bag`); composes with nested and concrete-instantiation + aliases (`type UserMap = Pair`). +- File-local, single-head bodies (v1). Cyclic, arity-mismatched, + class-colliding, duplicate, and unsupported-body aliases are loud + compile errors in both `compile` and `check`, each with a stable code. +- See the [type aliases](syntax/type-aliases.md) tour and the + [file-local / single-head caveat](caveats.md#type-aliases-are-file-local-and-single-head). + ### Naming and collisions - SHA-256-based generated FQCN; namespace mirrors the template. @@ -264,7 +280,6 @@ to ship. ### Generic surface -- Generic type aliases (e.g. `type Pair = ...`). - Variance edges on trait-owned templates. - Branching narrowing precision: today a turbofish call on a receiver whose branch arms disagree is a compile error; could track unions with diff --git a/docs/syntax/index.md b/docs/syntax/index.md index cfc65451..a95af6f0 100644 --- a/docs/syntax/index.md +++ b/docs/syntax/index.md @@ -22,6 +22,7 @@ first. | [Pseudo-types](pseudo-types.md) | `self` / `static` / `parent` and the `new self::(...)` form | | [Turbofish](turbofish.md) | All four call-site shapes plus variable and empty turbofish | | [Array sugar](array-sugar.md) | `T[]` shorthand | +| [Type aliases](type-aliases.md) | `type Pair = …;`, compile-time substitution, file-local | | [Exceptions](exceptions.md) | Generic exceptions, `catch (HttpError $e)`, bare and union catch | ## Quick reference card diff --git a/docs/syntax/type-aliases.md b/docs/syntax/type-aliases.md new file mode 100644 index 00000000..0214ac11 --- /dev/null +++ b/docs/syntax/type-aliases.md @@ -0,0 +1,105 @@ +# Type aliases + +A type alias gives a name to a type — generic or not — so you can write +it once and reuse it. It's a **compile-time substitution**: the alias is +expanded into its body before specialization and has no runtime +existence, so the emitted PHP never mentions the alias name. + +```php +type Pair = Dict>; // generic alias +type UserId = Ident; // non-generic alias (a plain class) +type UserMap = Pair; // a concrete instantiation of another alias +``` + +## Example + +```php + = Dict>; +type UserId = Ident; + +class Service { + public function pair(): Pair { + return new Pair::(1, new Bag::(new User())); + } + + public function id(): UserId { + return new UserId(); + } +} +``` + +## What gets emitted + +Each use is replaced by its expanded body, then monomorphized exactly as +if you had written the body by hand. `Pair` expands to +`Dict>` (a real specialization); `UserId` expands to the +plain class `Ident`. The `type …` declarations themselves vanish. + +```php +namespace App; + +class Service { + public function pair(): \XPHP\Generated\App\Dict\T_ { + return new \XPHP\Generated\App\Dict\T_(1, new \XPHP\Generated\App\Bag\T_(new User())); + } + public function id(): \App\Ident { + return new \App\Ident(); + } +} +``` + +Because expansion happens before specialization, an aliased generic +records and specializes the same class an explicit type would — there is +no separate code path and no runtime cost. + +## Rules + +- **Two forms**: `type Name = Body;` (generic) and + `type Name = Body;` (non-generic). The parameter list is optional; the + separator is `=`. +- The alias expands in **every type position** — parameter, return, + property, `new`, turbofish argument, `extends`/`implements`, and as a + **generic argument** of another type (`Bag`). +- Aliases compose: an alias body may reference another alias + (`type UserMap = Pair`), and an alias may take type + parameters used inside its body (`type Pair = Dict>`). +- A non-alias name of the same shape is untouched — only a declared alias + is expanded. +- The following are compile errors (each with a stable code, reported by + both `xphp compile` and `xphp check`): + - `xphp.alias_cycle` — an alias defined, directly or transitively, in + terms of itself (`type A = B; type B = A;`). + - `xphp.alias_arity` — a use whose type-argument count differs from the + alias's parameter count (`type P = …;` used as `P`). + - `xphp.alias_class_collision` — an alias whose name collides with a + class, interface, or trait of the same name (no silent shadowing). + - `xphp.alias_duplicate` — the same alias name declared twice. + - `xphp.alias_unsupported_body` — see caveats below. + +## Caveats + +Aliases are intentionally a small, safe first step. See +[caveats → type aliases](../caveats.md#type-aliases-are-file-local-and-single-head) +for the details and the reasons: + +- **File-local.** An alias is usable only within the file that declares + it (and only within its declaring namespace). Cross-file / importable + aliases are not supported yet. +- **Single-head bodies.** The body must be a single class or generic type + (`Dict`, `Ident`, `Bag`). A union, intersection, nullable, or + closure-signature body (`int|string`, `?Box`, `Closure(int): int`) is + rejected with `xphp.alias_unsupported_body` — use a bare type or a named + class. +- **Same-file collision detection.** An alias colliding with a class + declared in *another* file is not detected. + +## See also + +- Test fixture: `test/fixture/compile/type_aliases/` +- Related: [classes and interfaces](classes-and-interfaces.md), + [turbofish](turbofish.md) From 89bab9de5ae19b75b09f317feddba2c68f41df56 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 30 Jul 2026 19:40:09 +0000 Subject: [PATCH 33/46] feat(monomorphize): union and nullable type-alias bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift the single-head-only restriction for union (`A|B|…`) and nullable (`?X`) alias bodies, which expand into a real PHP `UnionType` / `NullableType` where a slot can hold one. `?X` desugars to `X|null`; a three-member `A|B|null` stays a `UnionType`, while `?X` (one non-null, atomic) emits `?X` (`?(A&B)` would be a fatal parse error). A compound (union) alias is only representable as the WHOLE type of a param / property / return / class-const slot — threaded via a wholeSlot flag from markType. As a generic argument, in `new` / turbofish / `extends` / a bound, or nested inside another nullable/union at the use site, it is rejected loudly (`xphp.alias_compound_in_non_slot`) in both modes. Intersection, DNF, and closure-signature bodies remain `xphp.alias_unsupported_body` (a later change). A single-head alias that transitively resolves to a union expands as a union too. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 233 ++++++++++++++---- .../Monomorphize/TypeAliasIntegrationTest.php | 52 +++- .../Monomorphize/XphpSourceParserTest.php | 12 +- .../compile/type_aliases/source/Types.xphp | 10 + .../compile/type_aliases/verify/runtime.php | 4 + 5 files changed, 258 insertions(+), 53 deletions(-) diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 92239d5a..66900aa3 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -110,12 +110,18 @@ final class XphpSourceParser // tagged (the escape hatch). Advisory metadata only — not emitted. public const ATTR_SUSPECT_UNDECLARED_TYPE = 'xphp:suspectUndeclaredType'; - /** Stable diagnostic codes for type-alias (WI-01) rejections. */ + // Set on a type-hint Name that is the WHOLE type of a param / property / return / class-const + // slot (not nested inside a nullable/union/intersection). A compound-body alias may only expand + // here — elsewhere it has no representable form and is rejected. + public const ATTR_ALIAS_WHOLE_SLOT = 'xphp:aliasWholeSlot'; + + /** Stable diagnostic codes for type-alias rejections. */ public const CODE_ALIAS_CYCLE = 'xphp.alias_cycle'; public const CODE_ALIAS_ARITY = 'xphp.alias_arity'; public const CODE_ALIAS_DUPLICATE = 'xphp.alias_duplicate'; public const CODE_ALIAS_CLASS_COLLISION = 'xphp.alias_class_collision'; public const CODE_ALIAS_UNSUPPORTED_BODY = 'xphp.alias_unsupported_body'; + public const CODE_ALIAS_COMPOUND_IN_NON_SLOT = 'xphp.alias_compound_in_non_slot'; /** * The reserved PHP type keywords — names PHP forbids as class names. A bare name in this list is @@ -314,7 +320,7 @@ public function strip(string $source): string } /** - * @return array{0: list}>, 1: list}>, 2: list}>, 3: string, 4: ByteOffsetMap, 5: list, 6: list, body:?TypeRef, bytePosition:int, line:int}>} + * @return array{0: list}>, 1: list}>, 2: list}>, 3: string, 4: ByteOffsetMap, 5: list, 6: list, body:?list, bytePosition:int, line:int}>} */ private function scanAndStrip(string $source): array { @@ -328,7 +334,7 @@ private function scanAndStrip(string $source): array $methodMarkers = []; /** @var list $closureMarkers */ $closureMarkers = []; - /** @var list, body:?TypeRef, bytePosition:int, line:int}> $aliasMarkers */ + /** @var list, body:?list, bytePosition:int, line:int}> $aliasMarkers */ $aliasMarkers = []; /** @var list $replacements [byte offset, original length, replacement text] */ $replacements = []; @@ -2434,7 +2440,7 @@ private static function parseTypeArgList(array $tokens, int $openIdx): ?array * a member (`Foo::type`, `$x->type`, `new type()`) is never mistaken for a declaration. * * @param list $tokens - * @return array{0: array{name:string, paramNames:list, body:?TypeRef, bytePosition:int, line:int}, 1: int}|null + * @return array{0: array{name:string, paramNames:list, body:?list, bytePosition:int, line:int}, 1: int}|null */ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ?array { @@ -2499,15 +2505,11 @@ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ? return null; } - // A single (possibly-generic) head immediately followed by that `;` is a supported body. - // Anything else (union / intersection / nullable / closure) is recorded with a null body: - // the whole statement is still stripped here (so `strip()` never produces a PHP parse error), - // and `buildAliasTable` rejects the null body with a clear `xphp.alias_unsupported_body` - // diagnostic at parse time. - $bodyParsed = self::parseTypeArg($tokens, $bodyStart); - $body = ($bodyParsed !== null && self::skipWs($tokens, $bodyParsed[1]) === $semiIdx) - ? $bodyParsed[0] - : null; + // The body is a single head or a flat union of single heads (`?X` desugars to `X|null`); + // anything else (intersection, DNF, closure signature) yields a null body. The whole + // statement is still stripped here (so `strip()` never produces a PHP parse error); a null + // body is rejected with `xphp.alias_unsupported_body` at parse time by `buildAliasTable`. + $body = self::parseAliasBody($tokens, $bodyStart, $semiIdx); return [ [ @@ -2521,6 +2523,54 @@ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ? ]; } + /** + * Parse a type-alias body (between `=`, starting at $bodyStart, and its terminator $semiIdx) as a + * flat union of single heads. Returns the union members — a single-head body is one member, and + * `?X` desugars to `[X, null]`. Returns null when the body is a shape v1/v2 does not support: an + * intersection (`&`), a parenthesised / DNF form, or a closure signature; `buildAliasTable` then + * rejects the null body with `xphp.alias_unsupported_body`. + * + * @param list $tokens + * @return list|null + */ + private static function parseAliasBody(array $tokens, int $bodyStart, int $semiIdx): ?array + { + // Leading `?` → nullable: `?` desugars to ` | null`. A `?` in front of a + // compound (`?A|B`) is illegal PHP anyway, so only a single head may follow. + // @infection-ignore-all NullSafePropertyCall -- `$bodyStart <= $semiIdx < count`, so the token + // always exists; the `?? null` / `?->` is a defensive floor that never sees null. + if (($tokens[$bodyStart] ?? null)?->text === '?') { + $parsed = self::parseTypeArg($tokens, self::skipWs($tokens, $bodyStart + 1)); + if ($parsed === null || self::skipWs($tokens, $parsed[1]) !== $semiIdx) { + return null; + } + return [$parsed[0], new TypeRef('null')]; + } + + // Otherwise a union of single heads: `Head ( '|' Head )*`. A non-head member (an intersection + // `&`, a `(` DNF group, a closure `(`) leaves a token that is neither the terminator nor `|`, + // so the body is declined as unsupported. + $members = []; + $i = $bodyStart; + while (true) { + $parsed = self::parseTypeArg($tokens, $i); + if ($parsed === null) { + return null; + } + $members[] = $parsed[0]; + $next = self::skipWs($tokens, $parsed[1]); + if ($next === $semiIdx) { + return $members; + } + // @infection-ignore-all NullSafePropertyCall -- `$next <= $semiIdx < count`, so the token + // always exists; the `?? null` / `?->` is a defensive floor that never sees null. + if (($tokens[$next] ?? null)?->text !== '|') { + return null; + } + $i = self::skipWs($tokens, $next + 1); + } + } + /** * The index of the `;` that terminates an alias statement whose body starts at $bodyStart, or * null when a `{` / `}` / end of input is reached first (a truncated, mid-typing declaration). @@ -2842,8 +2892,8 @@ private static function applyReplacements(string $source, array $replacements): * duplicate-alias diagnostic lands in a later change). * * @param list $ast - * @param list, body:?TypeRef, bytePosition:int, line:int}> $aliasMarkers - * @return array, body:TypeRef}> + * @param list, body:?list, bytePosition:int, line:int}> $aliasMarkers + * @return array, body:list}> */ private static function buildAliasTable(array $ast, array $aliasMarkers, ByteOffsetMap $byteOffsetMap): array { @@ -2960,7 +3010,7 @@ private static function collectClassLikeFqns(array $ast): array * @param list}> $nameMarkers * @param list}> $methodMarkers * @param list $closureMarkers - * @param list, body:?TypeRef, bytePosition:int, line:int}> $aliasMarkers + * @param list, body:?list, bytePosition:int, line:int}> $aliasMarkers */ private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers, array $closureMarkers, ByteOffsetMap $byteOffsetMap, array $aliasMarkers): ?string { @@ -2985,7 +3035,7 @@ class($classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetM * @param array}> $nameMarkers * @param array}> $methodMarkers * @param array $closureMarkers - * @param array, body:TypeRef}> $aliasTable file-local + * @param array, body:list}> $aliasTable file-local * type aliases keyed by FQN; body is the raw (unresolved) TypeRef. */ public function __construct( @@ -3003,7 +3053,7 @@ public function __construct( * Cache of resolved alias bodies keyed by alias FQN — the raw body is resolved once * (against the use-site namespace context, with the alias's params in scope) and reused. * - * @var array + * @var array> */ private array $aliasBodyCache = []; @@ -3354,7 +3404,9 @@ public function enterNode(Node $node): ?Node // Use_ branches (so $ctx is populated) and after the ClassLike/ // method type-param push (so isEnclosingTypeParam sees this scope). if ($node instanceof Node\Stmt\Class_) { - $this->markType($node->extends); + // `extends` needs a single class, so a compound alias there must reject — + // wholeSlot:false (a single-head alias still expands regardless of the flag). + $this->markType($node->extends, false); foreach ($node->implements as $impl) { $this->markName($impl); } @@ -3380,17 +3432,17 @@ public function enterNode(Node $node): ?Node $this->markName($type); } } elseif ($node instanceof Node\Param) { - $this->markType($node->type); + $this->markType($node->type, true); } elseif ($node instanceof Node\Stmt\Property) { - $this->markType($node->type); + $this->markType($node->type, true); } elseif ($node instanceof Node\Stmt\ClassConst) { - $this->markType($node->type); + $this->markType($node->type, true); } elseif ($node instanceof Node\Stmt\ClassMethod || $node instanceof Node\Stmt\Function_ || $node instanceof Node\Expr\Closure || $node instanceof Node\Expr\ArrowFunction ) { - $this->markType($node->returnType); + $this->markType($node->returnType, true); } return null; @@ -3401,16 +3453,22 @@ public function enterNode(Node $node): ?Node * through nullable/union/intersection wrappers). Scalar `Identifier` * leaves and non-Name expressions are left untouched. */ - private function markType(?Node $type): void + private function markType(?Node $type, bool $wholeSlot): void { if ($type instanceof Name) { $this->attachClosureSig($type); $this->markName($type); + // A Name that IS the whole slot type may expand to a compound (union) alias; a Name + // reached through the nullable/union/intersection recursion below is nested and may + // not (tagged only at the top level). + if ($wholeSlot) { + $type->setAttribute(XphpSourceParser::ATTR_ALIAS_WHOLE_SLOT, true); + } } elseif ($type instanceof Node\NullableType) { - $this->markType($type->type); + $this->markType($type->type, false); } elseif ($type instanceof Node\UnionType || $type instanceof Node\IntersectionType) { foreach ($type->types as $inner) { - $this->markType($inner); + $this->markType($inner, false); } } } @@ -3957,23 +4015,75 @@ private function expandAliasName(Name $node): ?Node return null; } // Expand the head AND (recursively) the arguments — an alias can appear as a generic - // argument of a non-alias type (`Bag`), not just as the head. If nothing was an - // alias the expansion is identical to the input, so the node is left untouched. + // argument of a non-alias type (`Bag`), not just as the head. The expansion is a + // union of members: one member is a single head (leave a non-alias untouched, else + // replace); two or more is a compound (union) alias, representable only as the whole + // type of a slot (`ATTR_ALIAS_WHOLE_SLOT`). $useRef = new TypeRef($head, $useArgs); - $expanded = $this->expandAlias($useRef, [], $node->getStartLine()); - if ($expanded->canonical() === $useRef->canonical()) { - return null; - } - // Drop the pre-expansion xphp attributes; typeRefToNode re-adds the right ones for - // the expanded head (position attributes are preserved so diagnostics still map back). + $members = $this->expandAliasToUnion($useRef, [], $node->getStartLine()); + // Drop the pre-expansion xphp attributes; the builders re-add the right ones (position + // attributes are preserved so diagnostics still map back). $attrs = $node->getAttributes(); unset( $attrs[XphpSourceParser::ATTR_GENERIC_ARGS], $attrs[XphpSourceParser::ATTR_TEMPLATE_FQN], $attrs[XphpSourceParser::ATTR_RESOLVED_FQN], $attrs[XphpSourceParser::ATTR_SUSPECT_UNDECLARED_TYPE], + $attrs[XphpSourceParser::ATTR_ALIAS_WHOLE_SLOT], ); - return Specializer::typeRefToNode($expanded, $attrs); + if (count($members) === 1) { + if ($members[0]->canonical() === $useRef->canonical()) { + return null; + } + return Specializer::typeRefToNode($members[0], $attrs); + } + if ($node->getAttribute(XphpSourceParser::ATTR_ALIAS_WHOLE_SLOT) !== true) { + throw new XphpParseException( + "Type alias `{$head}` is a union type, which is only usable as the whole type " + . 'of a parameter, property, return, or class-constant slot.', + $node->getStartLine(), + XphpSourceParser::CODE_ALIAS_COMPOUND_IN_NON_SLOT, + ); + } + return self::unionMembersToNode($members, $attrs); + } + + /** + * Build the PHP type node for an expanded union: a `NullableType` when the sole non-null + * member is atomic (`?X` ≡ `X|null`), otherwise a `UnionType` (with a `null` member when + * the union is nullable). Members are single heads, so `?X` never wraps a compound — + * `?(A&B)` would be a fatal PHP parse error. + * + * @param list $members + * @param array $attrs + */ + private static function unionMembersToNode(array $members, array $attrs): Node + { + $hasNull = false; + /** @var list $nonNull */ + $nonNull = []; + foreach ($members as $m) { + // @infection-ignore-all UnwrapStrToLower -- resolveTypeRef already lowercases a + // scalar keyword, so a `null` leaf's name is always lowercase here; strtolower is + // a belt-and-suspenders guard. + if (!$m->isGeneric() && strtolower($m->name) === 'null') { + $hasNull = true; + } else { + $nonNull[] = $m; + } + } + // A single-head member always lowers to an atomic Identifier (scalar) or Name (class), + // never a compound node — so it is valid inside a UnionType and (for the `?X` case) a + // NullableType. + /** @var list $nodes */ + $nodes = array_map(static fn (TypeRef $m): Node => Specializer::typeRefToNode($m, []), $nonNull); + if ($hasNull && count($nodes) === 1) { + return new Node\NullableType($nodes[0], $attrs); + } + if ($hasNull) { + $nodes[] = new Node\Identifier('null'); + } + return new Node\UnionType($nodes, $attrs); } /** @@ -3988,11 +4098,39 @@ private function expandAliasName(Name $node): ?Node * @param list $visited alias FQNs already entered on this expansion chain */ private function expandAlias(TypeRef $ref, array $visited, int $line): TypeRef + { + $members = $this->expandAliasToUnion($ref, $visited, $line); + if (count($members) !== 1) { + // A union alias reached where only a single head is representable — a generic + // argument, a `new` / turbofish / `extends` / bound, or a nested type position. + throw new XphpParseException( + "Type alias `{$ref->name}` is a union type, which is only usable as the whole " + . 'type of a parameter, property, return, or class-constant slot.', + $line, + XphpSourceParser::CODE_ALIAS_COMPOUND_IN_NON_SLOT, + ); + } + return $members[0]; + } + + /** + * Expand a type reference to its **union members** (a single-head result is one member), + * fully resolving aliases. A non-alias head yields itself with its generic arguments + * expanded (single-head — a union cannot be a generic argument, so an alias argument that + * expands to a union throws via `expandAlias`). An alias head substitutes its body's union + * members (params → arguments) and expands each recursively, concatenating — so a + * single-head alias whose body transitively resolves to a union becomes a union too, and a + * union member that is itself a union alias flattens in. A cycle or arity mismatch throws. + * + * @param list $visited alias FQNs already entered on this expansion chain + * @return list + */ + private function expandAliasToUnion(TypeRef $ref, array $visited, int $line): array { $expandedArgs = array_map(fn (TypeRef $a): TypeRef => $this->expandAlias($a, [], $line), $ref->args); $entry = $this->aliasTable[$ref->name] ?? null; if ($entry === null) { - return new TypeRef($ref->name, $expandedArgs, $ref->isScalar, $ref->isTypeParam, $ref->suspectUndeclared); + return [new TypeRef($ref->name, $expandedArgs, $ref->isScalar, $ref->isTypeParam, $ref->suspectUndeclared)]; } if (in_array($ref->name, $visited, true)) { throw new XphpParseException( @@ -4013,8 +4151,14 @@ private function expandAlias(TypeRef $ref, array $visited, int $line): TypeRef foreach ($entry['paramNames'] as $k => $paramName) { $subst[$paramName] = $expandedArgs[$k]; } - $substituted = self::substituteTypeRef($this->resolveAliasBody($ref->name, $entry), $subst); - return $this->expandAlias($substituted, [...$visited, $ref->name], $line); + $members = []; + foreach ($this->resolveAliasBody($ref->name, $entry) as $bodyMember) { + $substituted = self::substituteTypeRef($bodyMember, $subst); + foreach ($this->expandAliasToUnion($substituted, [...$visited, $ref->name], $line) as $m) { + $members[] = $m; + } + } + return $members; } /** @@ -4022,21 +4166,22 @@ private function expandAlias(TypeRef $ref, array $visited, int $line): TypeRef * type parameters pushed so `A` / `B` become type-param references rather than qualified * class names. Cached per alias FQN. * - * @param array{paramNames:list, body:TypeRef} $entry + * @param array{paramNames:list, body:list} $entry + * @return list */ - private function resolveAliasBody(string $fqn, array $entry): TypeRef + private function resolveAliasBody(string $fqn, array $entry): array { // @infection-ignore-all ReturnRemoval -- the cache is an optimization; resolveTypeRef // is deterministic for a fixed context, so re-resolving on a cache miss is equivalent. if (isset($this->aliasBodyCache[$fqn])) { return $this->aliasBodyCache[$fqn]; } - // Resolve the body with the alias's own parameters in scope, then restore the exact - // prior scope stack — so the alias's params never leak into later resolution. Restore - // by saved-copy assignment (not a pop) so the restore is exact and unconditional. + // Resolve each union member with the alias's own parameters in scope, then restore the + // exact prior scope stack — so the alias's params never leak into later resolution. + // Restore by saved-copy assignment (not a pop) so the restore is exact and unconditional. $saved = $this->typeParamStack; $this->typeParamStack[] = $entry['paramNames']; - $resolved = $this->resolveTypeRef($entry['body']); + $resolved = array_map(fn (TypeRef $m): TypeRef => $this->resolveTypeRef($m), $entry['body']); $this->typeParamStack = $saved; return $this->aliasBodyCache[$fqn] = $resolved; } diff --git a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php index 638913f4..4106e903 100644 --- a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php +++ b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php @@ -161,6 +161,50 @@ public function testAliasInGlobalNamespaceBlock(): void self::assertStringContainsString('Ident', $out); } + public function testUnionAndNullableBodiesExpandInWholeSlots(): void + { + // A union body expands into a param/property/return/class-const slot as a real `int|string`; + // a nullable body as `?\App\Ident`; a three-member union incl. null stays a `UnionType` (not + // `?int`); and a single-head alias transitively resolving to a union expands too. + $use = self::read($this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => " "class Bag {}\ntype Num = int|string;\nfunction f(): Bag { return new Bag::(); }", + 'new' => "type Num = int|string;\nfunction f(): int { \$x = new Num(); return 1; }", + 'extends' => "type Num = int|string;\nclass C extends Num {}", + 'nested-in-nullable' => "type Num = int|string;\nfunction f(?Num \$x): int { return 1; }", + 'nested-in-union' => "class Extra {}\ntype Num = int|string;\nfunction f(Num|Extra \$x): int { return 1; }", + ] as $body) { + $files = ['C.xphp' => "check($files), XphpSourceParser::CODE_ALIAS_COMPOUND_IN_NON_SLOT, $needle); + $this->assertCompileThrows($files, $needle); + } + } + + public function testNullableFollowedByUnionIsDeclinedAsUnsupported(): void + { + // `?A|B` is illegal PHP (`?` cannot precede a union); the body is declined (not mis-read as + // `?A`), so the declaration is an unsupported-body error rather than a wrong acceptance. + $files = ['C.xphp' => "check($files), XphpSourceParser::CODE_ALIAS_UNSUPPORTED_BODY, 'unsupported body'); + $this->assertCompileThrows($files, 'unsupported body'); + } + public function testCyclicAliasIsRejectedInBothModes(): void { // A directly-or-transitively self-referential alias would expand without bound; it is @@ -183,11 +227,11 @@ public function testAliasArityMismatchIsRejectedInBothModes(): void public function testUnsupportedAliasBodyIsRejectedInBothModes(): void { - // A union / nullable / intersection / closure body is recognized (stripped) but rejected with - // a clear diagnostic — not a raw PHP parse error. The full message is asserted so a reworded - // or truncated diagnostic is caught. + // An intersection (and DNF / closure) body is recognized (stripped) but rejected with a clear + // diagnostic — not a raw PHP parse error. (Union and nullable bodies ARE supported — see the + // union tests.) The full message is asserted so a reworded or truncated diagnostic is caught. $files = [ - 'C.xphp' => " "createForHostVersion()); @@ -199,6 +199,8 @@ public function testUnsupportedAliasBodyIsStrippedWhileReservedNameIsDeclined(): self::assertSame(self::withBlanked($union, 'type Num = int|float;'), $parser->strip($union)); $nullable = "strip($nullable)); + $intersection = "strip($intersection)); // A reserved word (`array`, T_ARRAY) is not a valid alias head, so the declaration is not // recognized and is left byte-for-byte intact. diff --git a/test/fixture/compile/type_aliases/source/Types.xphp b/test/fixture/compile/type_aliases/source/Types.xphp index bc73db48..af5ae7d6 100644 --- a/test/fixture/compile/type_aliases/source/Types.xphp +++ b/test/fixture/compile/type_aliases/source/Types.xphp @@ -11,6 +11,9 @@ type Pair = Dict>; type UserId = Ident; type UserMap = Pair; type Elem = User; // used only as a generic ARGUMENT (`Bag`) +type Num = int|string; // union body — expands into a whole slot as `int|string` +type MaybeUser = ?User; // nullable body — expands as `?User` +type Aliased = Num; // single head that transitively resolves to a union class Ident {} class User {} @@ -39,6 +42,10 @@ class Service { return new UserId(); } + + // Union / nullable / transitively-union alias uses in whole-slot positions. + public function num(Num $x): Aliased { return $x; } + public function maybe(): MaybeUser { return null; } } // Driver: the runtime verify reads these top-level values after requiring the emitted file. @@ -49,3 +56,6 @@ $userMap = new UserMap(2, new Bag::(new User())); // `Bag` — an alias in generic-argument position. If it did not expand to `Bag`, the // generated specialization would be typed on the nonexistent class `App\Aliases\Elem` and fatal here. $elemBag = new Bag::(new User()); +// Union / nullable slot expansion: if `Num` did not become `int|string`, passing a string would fatal. +$numValue = $service->num('hi'); +$maybeValue = $service->maybe(); diff --git a/test/fixture/compile/type_aliases/verify/runtime.php b/test/fixture/compile/type_aliases/verify/runtime.php index acd1106f..85d16007 100644 --- a/test/fixture/compile/type_aliases/verify/runtime.php +++ b/test/fixture/compile/type_aliases/verify/runtime.php @@ -35,4 +35,8 @@ // Bag === Bag: an alias in generic-argument position expanded; the item is a User. Assert::assertInstanceOf('App\\Aliases\\User', $elemBag->get(), 'Bag expanded to Bag'); + + // Union / nullable slots executed: `num('hi')` typed `int|string`, `maybe()` typed `?User`. + Assert::assertSame('hi', $numValue, 'union alias Num expanded to int|string in the param/return slots'); + Assert::assertNull($maybeValue, 'nullable alias MaybeUser expanded to ?User'); }; From 396b7e0b63e9e3735ffeb204801a5900cb66201a Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 30 Jul 2026 20:19:41 +0000 Subject: [PATCH 34/46] feat(monomorphize): whole-program (cross-file) type aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make an alias declared in one file usable in another. The Compiler runs a pre-pass over every source, merging each file's local alias table (XphpSourceParser::aliasTableOf) into one whole-program table, then injects it into the per-file parse so expansion resolves an alias no matter which file declares it. A file whose own aliases are malformed is skipped in the pre-pass; the same rejection re-surfaces (and is collected in check mode) when that file is parsed for real. A standalone parse (the LSP / tolerant path) keeps aliases file-local — the whole-program table is a compile/check concern. Same-file duplicate / collision / unsupported-body checks are unchanged; cross-file duplicate and collision are last-wins / undetected (a later refinement). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/Compiler.php | 53 ++++++++++++++++--- .../Monomorphize/XphpSourceParser.php | 46 +++++++++++++--- .../Monomorphize/TypeAliasIntegrationTest.php | 36 +++++++++++++ .../compile/type_aliases/source/Consumer.xphp | 16 ++++++ .../compile/type_aliases/verify/runtime.php | 4 ++ 5 files changed, 142 insertions(+), 13 deletions(-) create mode 100644 test/fixture/compile/type_aliases/source/Consumer.xphp diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 79a63b56..466d5f0f 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -455,13 +455,18 @@ private function specializeToFixedPoint( public function check(FilepathArray $sources): DiagnosticCollector { $diagnostics = new DiagnosticCollector(); - $astPerFile = []; + // Read every source up front — OUTSIDE the try so an I/O failure surfaces as itself, not a + // mislabeled "parse error" — then merge a whole-program alias table so a cross-file alias use + // resolves. Only parsing is treated as a per-file, recoverable diagnostic. + $contents = []; 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); + $contents[$filepath] = $this->fileReader->read($filepath); + } + $globalAliases = $this->collectGlobalAliases($contents); + $astPerFile = []; + foreach ($contents as $filepath => $content) { try { - $astPerFile[$filepath] = $this->sourceParser->parse($content); + $astPerFile[$filepath] = $this->sourceParser->parse($content, $globalAliases); } catch (PhpParserError $e) { $line = $e->getStartLine(); $diagnostics->add(new Diagnostic( @@ -577,14 +582,48 @@ public function check(FilepathArray $sources): DiagnosticCollector */ private function parseAll(FilepathArray $sources): array { - $astPerFile = []; + $contents = []; foreach ($sources->filepaths as $filepath) { - $astPerFile[$filepath] = $this->sourceParser->parse($this->fileReader->read($filepath)); + $contents[$filepath] = $this->fileReader->read($filepath); + } + $globalAliases = $this->collectGlobalAliases($contents); + + $astPerFile = []; + foreach ($contents as $filepath => $content) { + $astPerFile[$filepath] = $this->sourceParser->parse($content, $globalAliases); } return $astPerFile; } + /** + * Merge every source's file-local type-alias table into one whole-program table, so an alias + * declared in one file can be used in another. A file whose own aliases are malformed (same-file + * duplicate / collision / unsupported body) raises here and is skipped — the same error + * re-surfaces (and, in check mode, is collected) when that file is parsed for real. + * + * @param array $contents filepath => source + * @return array, body:list<\XPHP\Transpiler\Monomorphize\TypeRef>}> + */ + private function collectGlobalAliases(array $contents): array + { + $global = []; + foreach ($contents as $content) { + try { + foreach ($this->sourceParser->aliasTableOf($content) as $fqn => $entry) { + $global[$fqn] = $entry; + } + } catch (RuntimeException) { + // Any parse-time rejection — skip this file's aliases; the same error re-surfaces (and + // is collected in check mode) when the file is parsed for real. Both a nikic syntax + // error (PhpParser\Error) and an xphp scanner/alias error (XphpParseException) extend + // RuntimeException, so this catches every parse-time failure. + } + } + + return $global; + } + private static function relativePath(string $base, string $filepath): string { $base = rtrim($base, '/') . '/'; diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 66900aa3..c0414666 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -144,11 +144,39 @@ public function __construct(private readonly Parser $parser) } /** + * @param array, body:list}>|null $externalAliases + * a whole-program alias table (from {@see aliasTableOf} across every source) used for + * cross-file expansion; null keeps aliases file-local (standalone parse / LSP). * @return list */ - public function parse(string $source): array + public function parse(string $source, ?array $externalAliases = null): array { - return $this->parseWithMap($source)[0]; + return $this->parseWithMap($source, $externalAliases)[0]; + } + + /** + * The file-local type-alias table for a single source — its `type` / `use type` declarations + * keyed by FQN, bodies unresolved — WITHOUT expanding any uses. The Compiler merges these across + * every source into a whole-program table so an alias declared in one file is usable in another. + * Same-file duplicate / class-collision / unsupported-body rejections still fire (per file) via + * the main parse; the caller catches and skips a file that raises one here. + * + * @return array, body:list}> + */ + public function aliasTableOf(string $source): array + { + [, , , $cleaned, $byteOffsetMap, , $aliasMarkers] = $this->scanAndStrip($source); + // @infection-ignore-all ReturnRemoval -- optimization: an alias-free file (the common case) + // skips the re-parse; without it buildAliasTable([]) returns [] anyway. + if ($aliasMarkers === []) { + return []; + } + $ast = $this->parser->parse($cleaned); + if ($ast === null) { + return []; + } + /** @var list $ast — nikic's parse() returns array; keys are always 0..N-1. */ + return self::buildAliasTable($ast, $aliasMarkers, $byteOffsetMap); } /** @@ -160,9 +188,10 @@ public function parse(string $source): array * Returns the identity map when no length-changing replacements fired * (the common case for files without `T[]` array-suffix sugar). * + * @param array, body:list}>|null $externalAliases * @return array{0: list, 1: ByteOffsetMap} */ - public function parseWithMap(string $source): array + public function parseWithMap(string $source, ?array $externalAliases = null): array { [$classMarkers, $nameMarkers, $methodMarkers, $cleanedSource, $byteOffsetMap, $closureMarkers, $aliasMarkers] = $this->scanAndStrip($source); @@ -182,7 +211,7 @@ public function parseWithMap(string $source): array } /** @var list $ast — nikic's parse() returns array; runtime keys are always 0..N-1. */ - $unbound = $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap, $aliasMarkers); + $unbound = $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap, $aliasMarkers, $externalAliases); // @infection-ignore-all — defensive backstop, unreachable from valid input by // construction (see unboundDeclarationMarkerMessage): no test can reach a // mutant here. The message builder is pinned by direct unit tests; this @@ -3011,10 +3040,15 @@ private static function collectClassLikeFqns(array $ast): array * @param list}> $methodMarkers * @param list $closureMarkers * @param list, body:?list, bytePosition:int, line:int}> $aliasMarkers + * @param array, body:list}>|null $externalAliases */ - private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers, array $closureMarkers, ByteOffsetMap $byteOffsetMap, array $aliasMarkers): ?string + private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers, array $closureMarkers, ByteOffsetMap $byteOffsetMap, array $aliasMarkers, ?array $externalAliases = null): ?string { - $aliasTable = self::buildAliasTable($ast, $aliasMarkers, $byteOffsetMap); + // buildAliasTable runs the per-file rejections (same-file duplicate / class-collision / + // unsupported body) regardless; a whole-program table, when injected, is what expansion + // actually looks aliases up in so a use can reach an alias declared in another file. + $fileTable = self::buildAliasTable($ast, $aliasMarkers, $byteOffsetMap); + $aliasTable = $externalAliases ?? $fileTable; $traverser = new NodeTraverser(); $visitor = new /** diff --git a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php index 4106e903..ab4f3ed0 100644 --- a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php +++ b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php @@ -205,6 +205,42 @@ public function testNullableFollowedByUnionIsDeclinedAsUnsupported(): void $this->assertCompileThrows($files, 'unsupported body'); } + public function testAliasesAreVisibleAcrossFilesInTheSameBuild(): void + { + // Whole-program alias table: an alias declared in one file is usable in another (union and + // plain-class bodies both). + $dist = $this->compile([ + 'Types.xphp' => " " " "check($files), XphpSourceParser::CODE_ALIAS_DUPLICATE, 'declared more than once'); + } + + public function testAliasFileWithASyntaxErrorIsCollectedNotCrashed(): void + { + // The pre-pass re-parses an alias-bearing file to collect its aliases; a nikic SYNTAX error + // there (a PhpParserError, not an xphp RuntimeException) must be caught/skipped too, so check + // collects it for real rather than crashing the whole-program alias collection. + $files = [ + 'Broken.xphp' => "check($files)->hasErrors(), 'a syntax error in an alias file is collected, not crashed'); + } + public function testCyclicAliasIsRejectedInBothModes(): void { // A directly-or-transitively self-referential alias would expand without bound; it is diff --git a/test/fixture/compile/type_aliases/source/Consumer.xphp b/test/fixture/compile/type_aliases/source/Consumer.xphp new file mode 100644 index 00000000..7c6e75b4 --- /dev/null +++ b/test/fixture/compile/type_aliases/source/Consumer.xphp @@ -0,0 +1,16 @@ +widen('cross'); diff --git a/test/fixture/compile/type_aliases/verify/runtime.php b/test/fixture/compile/type_aliases/verify/runtime.php index 85d16007..694918b0 100644 --- a/test/fixture/compile/type_aliases/verify/runtime.php +++ b/test/fixture/compile/type_aliases/verify/runtime.php @@ -18,6 +18,7 @@ return function (CompiledFixture $fixture): void { require $fixture->targetDir . '/Types.php'; + require $fixture->targetDir . '/Consumer.php'; // Pair === Dict>: the value is a Bag specialization holding a User. Assert::assertInstanceOf('App\\Aliases\\User', $pair->value()->get(), 'Pair expanded to Dict>'); @@ -39,4 +40,7 @@ // Union / nullable slots executed: `num('hi')` typed `int|string`, `maybe()` typed `?User`. Assert::assertSame('hi', $numValue, 'union alias Num expanded to int|string in the param/return slots'); Assert::assertNull($maybeValue, 'nullable alias MaybeUser expanded to ?User'); + + // Cross-file: Consumer.xphp used `Num` declared in Types.xphp. + Assert::assertSame('cross', $crossValue, 'an alias declared in Types.xphp was usable in Consumer.xphp'); }; From b5df8cc41f6d463909f959dfcf10effbae957015 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 30 Jul 2026 20:24:56 +0000 Subject: [PATCH 35/46] docs(type-aliases): document union/nullable bodies and cross-file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the type-alias docs to the delivered feature: union and nullable bodies and cross-file (whole-program) use. Rewrite the caveat (renamed to body/position limits — file-local and single-head no longer apply) and repoint the syntax/roadmap/ADR anchors; add the `xphp.alias_compound_in_non_slot` code; refresh the roadmap Shipped entry, the ADR consequences, and the CHANGELOG. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 27 ++++---- .../adr/0023-type-alias-declaration-syntax.md | 9 +-- docs/caveats.md | 61 +++++++++---------- docs/roadmap.md | 18 +++--- docs/syntax/type-aliases.md | 44 +++++++------ .../Monomorphize/XphpSourceParser.php | 2 +- 6 files changed, 90 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8546cb54..56b65fd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,17 +9,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Type aliases.** `type Name = Body;` (generic) and `type Name = Body;` - (non-generic) give a type a reusable name. An alias is a compile-time - substitution — expanded into its body before specialization, with no runtime - existence, so the emitted PHP never mentions the alias. It expands in every type - position, including as a generic argument (`Bag`), and composes with - nested and concrete-instantiation aliases (`type UserMap = Pair`). - v1 is file-local with single-head bodies; a cyclic (`xphp.alias_cycle`), - arity-mismatched (`xphp.alias_arity`), class-colliding - (`xphp.alias_class_collision`), duplicate (`xphp.alias_duplicate`), or - unsupported-body (`xphp.alias_unsupported_body`, e.g. a union/nullable/closure - body) alias is a loud error in both `xphp compile` and `xphp check`. See +- **Type aliases.** Give a type a reusable name, in two forms: + `type Name = Body;` (generic) and `type Name = Body;` (non-generic). An + alias is a compile-time substitution — expanded into its body before + specialization, with no runtime existence, so the emitted PHP never mentions the + alias. Bodies may be a single + (possibly-generic) head, a **union** (`int|string`), or a **nullable** (`?Box`): + a single head expands in every type position (incl. as a generic argument, + `Bag`), while a union/nullable expands as the whole type of a parameter, + property, return, or class-constant slot. Aliases compose (nested and + concrete-instantiation, `type UserMap = Pair`), and an alias declared + in one file is usable in another (**cross-file**, whole-program). A cyclic + (`xphp.alias_cycle`), arity-mismatched (`xphp.alias_arity`), class-colliding + (`xphp.alias_class_collision`), duplicate (`xphp.alias_duplicate`), + unsupported-body (`xphp.alias_unsupported_body` — intersection / DNF / closure), + or compound-in-non-slot (`xphp.alias_compound_in_non_slot`) alias is a loud error + in both `xphp compile` and `xphp check`. See [type aliases](docs/syntax/type-aliases.md). - **Type-argument inference (optional turbofish).** A generic call or `new` whose type parameters are determined by the argument values no longer needs the `::<>` diff --git a/docs/adr/0023-type-alias-declaration-syntax.md b/docs/adr/0023-type-alias-declaration-syntax.md index 83879fb7..dc146c38 100644 --- a/docs/adr/0023-type-alias-declaration-syntax.md +++ b/docs/adr/0023-type-alias-declaration-syntax.md @@ -61,9 +61,10 @@ and needs no runtime identity. - Trade-off: for the *generic* case xphp defines surface ahead of PHP (which deferred it), a bet on the declaration-form consensus. The non-generic import form (`use type … as`) could be added later as a parity synonym without disturbing this decision. -- Trade-off: v1 is scoped to file-local, single-head bodies (see the - [caveat](../caveats.md#type-aliases-are-file-local-and-single-head)) — a safe subset, - with cross-file and richer bodies as later work. +- Trade-off: the delivered scope is single-head / union / nullable bodies, cross-file; + intersection / DNF / closure bodies and compound-in-non-slot positions are still + rejected (see the [caveat](../caveats.md#type-alias-body-and-position-limits)) — a safe + subset, with the richer bodies as later work. ### Confirmation @@ -103,7 +104,7 @@ verified in both `compile` and `check`. ## More Information - [Type aliases](../syntax/type-aliases.md) and the - [file-local / single-head caveat](../caveats.md#type-aliases-are-file-local-and-single-head). + [file-local / single-head caveat](../caveats.md#type-alias-body-and-position-limits). - [ADR-0001](0001-monomorphization-over-type-erasure.md) — monomorphization; [ADR-0002](0002-build-time-transpiler.md) — build-time transpiler (why a runtime alias symbol is unnecessary). diff --git a/docs/caveats.md b/docs/caveats.md index 560b707b..5c8731e2 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -89,51 +89,50 @@ wherever inference can't see the type. It's always accepted, and an inferred call is identical to the turbofished one — so adding a turbofish never changes behavior, only makes the type explicit. -## Type aliases are file-local and single-head +## Type-alias body and position limits -[Type aliases](syntax/type-aliases.md) (`type Name<…> = Body;`) are a compile-time -substitution — a deliberately small first step, with three boundaries. +[Type aliases](syntax/type-aliases.md) are a compile-time substitution. A single +head (`Ident`, `Box`), a union (`int|string`), and a nullable (`?Box`) body +are all supported, and an alias declared in one file is usable in another. Three +limits remain. ### ❌ What doesn't work ```php -// File Types.xphp -type UserId = Ident; +type Both = A & B; // ✗ xphp.alias_unsupported_body — intersection +type Dnf = (A & B) | C; // ✗ xphp.alias_unsupported_body — DNF +type Fn = Closure(int): int; // ✗ xphp.alias_unsupported_body — closure signature -// File Other.xphp — a DIFFERENT file -function f(): UserId { /* ... */ } // ✗ UserId is not visible here (file-local) - -type Num = int|string; // ✗ xphp.alias_unsupported_body — union body -type Maybe = ?Box; // ✗ xphp.alias_unsupported_body — nullable body -type Fn = Closure(int): int; // ✗ xphp.alias_unsupported_body — closure signature +// A union / nullable alias is only usable as the WHOLE type of a slot: +type Num = int|string; +function f(Num $n): void {} // ✓ whole param slot +function g(Bag $x): void {} // ✗ xphp.alias_compound_in_non_slot — generic argument +function h(Num&Extra $x): void {} // ✗ nested in another intersection/union +$b = new Num(); // ✗ compound alias in `new` / extends / a bound ``` -An alias colliding with a class in **another** file is also not detected (a -same-file collision is — `xphp.alias_class_collision`). +Cross-file, an alias colliding with a **class in another file**, or the same alias +declared in **two files**, is not detected (both are within one file — +`xphp.alias_class_collision` / `xphp.alias_duplicate`). ### Why -An alias is expanded before specialization, during the per-file parse: it has no -runtime existence, and the parse has no cross-file symbol table, so an alias is -scoped to the file (and namespace) that declares it. The body is restricted to a -single class or generic *head* because that is the shape the monomorphizer can -substitute directly into a type position; a union / intersection / nullable / -closure body has no single identity to carry through specialization, so it is -rejected loudly rather than mis-compiled. Both boundaries are the same "make the -safe subset solid first" trade the rest of xphp makes — they are candidates to -lift later, not permanent design limits. +The body is limited to a single head, a flat union, or a nullable because those +lower cleanly into a PHP type node. An intersection or DNF pulls in *distribution* +(`(A|B)&C → (A&C)|(B&C)`), and a union/nullable has no single identity to hash or +anchor, so it is representable only as the whole type of a param / property / +return / class-constant slot — anywhere else it is rejected loudly rather than +mis-compiled. Cross-file expansion is a whole-program pre-pass that merges each +file's alias table; global duplicate/collision checking across that merge is a +later refinement. These are "make the safe subset solid first" trades, not +permanent design limits. ### ✅ Workaround -- Keep an alias and its uses in the **same file**. For a shared vocabulary, - declare the alias in each file that needs it (it's a zero-cost substitution). -- For a non-single-head type, write the type directly, or wrap it in a named - class or interface and alias *that*: - -```php -type UserId = int|string; // ✗ rejected -interface UserId { /* marker */ } // ✓ a named type you can alias/reference -``` +- For an intersection / DNF / closure body, write the type directly, or wrap it in + a named class or interface and alias *that*. +- Use a union/nullable alias as the whole type of a slot; write the union directly + where you need it as a generic argument or nested in another compound type. --- diff --git a/docs/roadmap.md b/docs/roadmap.md index 6fcd6cbd..073cc5ff 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -52,7 +52,8 @@ timeline : marker interface per template Type aliases : compile-time substitution - : file-local single-head bodies + : single-head union and nullable bodies + : whole-program cross-file use Developer experience : RFC-aligned call-site syntax : empty turbofish for all-defaults templates @@ -226,14 +227,17 @@ upcoming one. - `type Name = Body;` and `type Name = Body;` — a compile-time substitution expanded into its body before specialization, with no runtime existence (the emitted PHP never mentions the alias). -- Expands in every type position, including as a generic argument - (`Bag`); composes with nested and concrete-instantiation - aliases (`type UserMap = Pair`). -- File-local, single-head bodies (v1). Cyclic, arity-mismatched, - class-colliding, duplicate, and unsupported-body aliases are loud +- Single-head, **union** (`int|string`), and **nullable** (`?Box`) bodies. + A single head expands in every type position (incl. as a generic + argument, `Bag`); a union/nullable expands as the whole type of a + slot. Composes with nested and concrete-instantiation aliases. +- **Cross-file**: an alias declared in one file is usable in another + (whole-program alias table). +- Cyclic, arity-mismatched, class-colliding, duplicate, unsupported-body + (intersection / DNF / closure), and compound-in-non-slot uses are loud compile errors in both `compile` and `check`, each with a stable code. - See the [type aliases](syntax/type-aliases.md) tour and the - [file-local / single-head caveat](caveats.md#type-aliases-are-file-local-and-single-head). + [body / position limits caveat](caveats.md#type-alias-body-and-position-limits). ### Naming and collisions diff --git a/docs/syntax/type-aliases.md b/docs/syntax/type-aliases.md index 0214ac11..dacb3626 100644 --- a/docs/syntax/type-aliases.md +++ b/docs/syntax/type-aliases.md @@ -9,6 +9,8 @@ existence, so the emitted PHP never mentions the alias name. type Pair = Dict>; // generic alias type UserId = Ident; // non-generic alias (a plain class) type UserMap = Pair; // a concrete instantiation of another alias +type Num = int|string; // union body +type MaybeUser = ?User; // nullable body ``` ## Example @@ -59,12 +61,17 @@ no separate code path and no runtime cost. ## Rules -- **Two forms**: `type Name = Body;` (generic) and +- **Declaration forms**: `type Name = Body;` (generic) and `type Name = Body;` (non-generic). The parameter list is optional; the separator is `=`. -- The alias expands in **every type position** — parameter, return, - property, `new`, turbofish argument, `extends`/`implements`, and as a - **generic argument** of another type (`Bag`). +- **Bodies**: a single (possibly-generic) head (`Ident`, `Dict`), a + **union** (`int|string`), or a **nullable** (`?Box`). A single-head or + generic body expands in **every** type position, including as a generic + argument (`Bag`), `new`, `extends`, and a bound. A **union / + nullable** body expands only as the *whole* type of a parameter, + property, return, or class-constant slot (see caveats). +- **Cross-file**: an alias declared in one file is usable in another file + of the same build (the whole program shares one alias table). - Aliases compose: an alias body may reference another alias (`type UserMap = Pair`), and an alias may take type parameters used inside its body (`type Pair = Dict>`). @@ -79,24 +86,27 @@ no separate code path and no runtime cost. - `xphp.alias_class_collision` — an alias whose name collides with a class, interface, or trait of the same name (no silent shadowing). - `xphp.alias_duplicate` — the same alias name declared twice. - - `xphp.alias_unsupported_body` — see caveats below. + - `xphp.alias_unsupported_body` — an intersection / DNF / closure-signature + body (see caveats below). + - `xphp.alias_compound_in_non_slot` — a union / nullable alias used + outside a whole slot (see caveats below). ## Caveats -Aliases are intentionally a small, safe first step. See -[caveats → type aliases](../caveats.md#type-aliases-are-file-local-and-single-head) +Union and nullable bodies and cross-file use all work; the remaining +limits are the body shape and the positions a compound alias can take. See +[caveats → type-alias body and position limits](../caveats.md#type-alias-body-and-position-limits) for the details and the reasons: -- **File-local.** An alias is usable only within the file that declares - it (and only within its declaring namespace). Cross-file / importable - aliases are not supported yet. -- **Single-head bodies.** The body must be a single class or generic type - (`Dict`, `Ident`, `Bag`). A union, intersection, nullable, or - closure-signature body (`int|string`, `?Box`, `Closure(int): int`) is - rejected with `xphp.alias_unsupported_body` — use a bare type or a named - class. -- **Same-file collision detection.** An alias colliding with a class - declared in *another* file is not detected. +- **Intersection / DNF / closure bodies** (`A&B`, `(A&B)|C`, + `Closure(int): int`) are rejected with `xphp.alias_unsupported_body` — + write the type directly or wrap it in a named class/interface. +- **A union / nullable alias is a whole-slot type only.** As a generic + argument, in `new` / `extends` / a bound, or nested inside another + union/intersection, it is `xphp.alias_compound_in_non_slot`. +- **Cross-file collision / duplicate not detected.** An alias colliding + with a class, or the same alias declared, in a *different* file is not + flagged (both are within one file). ## See also diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index c0414666..21afd094 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -155,7 +155,7 @@ public function parse(string $source, ?array $externalAliases = null): array } /** - * The file-local type-alias table for a single source — its `type` / `use type` declarations + * The file-local type-alias table for a single source — its `type` declarations * keyed by FQN, bodies unresolved — WITHOUT expanding any uses. The Compiler merges these across * every source into a whole-program table so an alias declared in one file is usable in another. * Same-file duplicate / class-collision / unsupported-body rejections still fire (per file) via From 0dbd66360240debe057125ccff1151a2be74278d Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 30 Jul 2026 23:03:11 +0000 Subject: [PATCH 36/46] feat(monomorphize): apply defaults for generic type-alias parameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A type alias's own parameters may declare defaults (`type P = Dict;`), which `parseTypeParamList` already parses but expansion discarded — using fewer args than params was a flat `xphp.alias_arity`. Retain the full per-param entries in the alias marker/table, resolve each default against the alias's params (so `B = A` and chained `C = B` fill from earlier arguments), and pad missing trailing arguments at expansion. The valid arity is now `required <= given <= total`; the message keeps the exact `expects N` form with no defaults and uses `between R and N` only when defaults make the count a range. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/Compiler.php | 4 +- .../Monomorphize/XphpSourceParser.php | 139 ++++++++++++++---- .../Monomorphize/TypeAliasIntegrationTest.php | 66 +++++++++ 3 files changed, 178 insertions(+), 31 deletions(-) diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 466d5f0f..a9c4dbe2 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -31,6 +31,8 @@ * 4. Emit rewritten user code — rewrite each original source AST (strip generic class defs, * rewrite generic Name references), pretty-print, and write to the target directory. * 5. Persist registry — write .xphp-cache/registry.json. + * + * @phpstan-import-type BoundDict from XphpSourceParser */ final readonly class Compiler { @@ -603,7 +605,7 @@ private function parseAll(FilepathArray $sources): array * re-surfaces (and, in check mode, is collected) when that file is parsed for real. * * @param array $contents filepath => source - * @return array, body:list<\XPHP\Transpiler\Monomorphize\TypeRef>}> + * @return array, body:list<\XPHP\Transpiler\Monomorphize\TypeRef>}> */ private function collectGlobalAliases(array $contents): array { diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 21afd094..43470d62 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -144,7 +144,7 @@ public function __construct(private readonly Parser $parser) } /** - * @param array, body:list}>|null $externalAliases + * @param array, body:list}>|null $externalAliases * a whole-program alias table (from {@see aliasTableOf} across every source) used for * cross-file expansion; null keeps aliases file-local (standalone parse / LSP). * @return list @@ -161,7 +161,7 @@ public function parse(string $source, ?array $externalAliases = null): array * Same-file duplicate / class-collision / unsupported-body rejections still fire (per file) via * the main parse; the caller catches and skips a file that raises one here. * - * @return array, body:list}> + * @return array, body:list}> */ public function aliasTableOf(string $source): array { @@ -188,7 +188,7 @@ public function aliasTableOf(string $source): array * Returns the identity map when no length-changing replacements fired * (the common case for files without `T[]` array-suffix sugar). * - * @param array, body:list}>|null $externalAliases + * @param array, body:list}>|null $externalAliases * @return array{0: list, 1: ByteOffsetMap} */ public function parseWithMap(string $source, ?array $externalAliases = null): array @@ -349,7 +349,7 @@ public function strip(string $source): string } /** - * @return array{0: list}>, 1: list}>, 2: list}>, 3: string, 4: ByteOffsetMap, 5: list, 6: list, body:?list, bytePosition:int, line:int}>} + * @return array{0: list}>, 1: list}>, 2: list}>, 3: string, 4: ByteOffsetMap, 5: list, 6: list, body:?list, bytePosition:int, line:int}>} */ private function scanAndStrip(string $source): array { @@ -363,7 +363,7 @@ private function scanAndStrip(string $source): array $methodMarkers = []; /** @var list $closureMarkers */ $closureMarkers = []; - /** @var list, body:?list, bytePosition:int, line:int}> $aliasMarkers */ + /** @var list, body:?list, bytePosition:int, line:int}> $aliasMarkers */ $aliasMarkers = []; /** @var list $replacements [byte offset, original length, replacement text] */ $replacements = []; @@ -2469,7 +2469,7 @@ private static function parseTypeArgList(array $tokens, int $openIdx): ?array * a member (`Foo::type`, `$x->type`, `new type()`) is never mistaken for a declaration. * * @param list $tokens - * @return array{0: array{name:string, paramNames:list, body:?list, bytePosition:int, line:int}, 1: int}|null + * @return array{0: array{name:string, params:list, body:?list, bytePosition:int, line:int}, 1: int}|null */ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ?array { @@ -2497,9 +2497,10 @@ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ? } // Optional `` parameter list. Parsed permissively (defaults + variance allowed, as on - // a class header) so recognition never throws; whether an alias param may carry a default or - // variance marker is a semantic question for the expansion step, not for scan-time stripping. - $paramNames = []; + // a class header) so recognition never throws. The full per-param entries — carrying each + // param's optional bound and default — are retained (not just the names): expansion applies + // the defaults (fewer args than params) and enforces the bounds. + $params = []; $afterName = self::skipWs($tokens, $nameIdx + 1); $afterNameTok = $tokens[$afterName] ?? null; if ($afterNameTok === null) { @@ -2510,8 +2511,7 @@ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ? if ($parsed === null) { return null; } - [$paramEntries, $paramsEndIdx] = $parsed; - $paramNames = array_map(static fn (array $entry): string => $entry['name'], $paramEntries); + [$params, $paramsEndIdx] = $parsed; // @infection-ignore-all IncrementInteger -- the `>` closing the param list is followed // by whitespace-then-`=` in every reachable shape (a no-space `>=` is the comparison // operator, not this position), so skipWs(+1) and skipWs(+2) reach the same token. @@ -2543,7 +2543,7 @@ private static function tryParseAliasDeclaration(array $tokens, int $typeIdx): ? return [ [ 'name' => $nameTok->text, - 'paramNames' => $paramNames, + 'params' => $params, 'body' => $body, 'bytePosition' => $tokens[$typeIdx]->pos, 'line' => $tokens[$typeIdx]->line, @@ -2921,8 +2921,8 @@ private static function applyReplacements(string $source, array $replacements): * duplicate-alias diagnostic lands in a later change). * * @param list $ast - * @param list, body:?list, bytePosition:int, line:int}> $aliasMarkers - * @return array, body:list}> + * @param list, body:?list, bytePosition:int, line:int}> $aliasMarkers + * @return array, body:list}> */ private static function buildAliasTable(array $ast, array $aliasMarkers, ByteOffsetMap $byteOffsetMap): array { @@ -2983,7 +2983,7 @@ private static function buildAliasTable(array $ast, array $aliasMarkers, ByteOff self::CODE_ALIAS_CLASS_COLLISION, ); } - $table[$fqn] = ['paramNames' => $marker['paramNames'], 'body' => $marker['body']]; + $table[$fqn] = ['params' => $marker['params'], 'body' => $marker['body']]; } return $table; } @@ -3039,8 +3039,8 @@ private static function collectClassLikeFqns(array $ast): array * @param list}> $nameMarkers * @param list}> $methodMarkers * @param list $closureMarkers - * @param list, body:?list, bytePosition:int, line:int}> $aliasMarkers - * @param array, body:list}>|null $externalAliases + * @param list, body:?list, bytePosition:int, line:int}> $aliasMarkers + * @param array, body:list}>|null $externalAliases */ private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers, array $closureMarkers, ByteOffsetMap $byteOffsetMap, array $aliasMarkers, ?array $externalAliases = null): ?string { @@ -3069,7 +3069,7 @@ class($classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetM * @param array}> $nameMarkers * @param array}> $methodMarkers * @param array $closureMarkers - * @param array, body:list}> $aliasTable file-local + * @param array, body:list}> $aliasTable file-local * type aliases keyed by FQN; body is the raw (unresolved) TypeRef. */ public function __construct( @@ -3091,6 +3091,14 @@ public function __construct( */ private array $aliasBodyCache = []; + /** + * Cache of resolved alias parameter defaults keyed by alias FQN — aligned by position to + * the alias's params, null where a param has no default. Resolved once, like the body. + * + * @var array> + */ + private array $aliasDefaultsCache = []; + // Returns a replacement Node when a type-alias use is expanded in place (the traverser // swaps it into the parent slot); null in every other case leaves the node untouched. public function enterNode(Node $node): ?Node @@ -4173,17 +4181,10 @@ private function expandAliasToUnion(TypeRef $ref, array $visited, int $line): ar XphpSourceParser::CODE_ALIAS_CYCLE, ); } - if (count($expandedArgs) !== count($entry['paramNames'])) { - throw new XphpParseException( - "Type alias `{$ref->name}` expects " . count($entry['paramNames']) - . ' type argument(s), ' . count($expandedArgs) . ' given.', - $line, - XphpSourceParser::CODE_ALIAS_ARITY, - ); - } + $paddedArgs = $this->padAliasArgs($ref->name, $entry, $expandedArgs, $line); $subst = []; - foreach ($entry['paramNames'] as $k => $paramName) { - $subst[$paramName] = $expandedArgs[$k]; + foreach (array_column($entry['params'], 'name') as $k => $paramName) { + $subst[$paramName] = $paddedArgs[$k]; } $members = []; foreach ($this->resolveAliasBody($ref->name, $entry) as $bodyMember) { @@ -4195,12 +4196,62 @@ private function expandAliasToUnion(TypeRef $ref, array $visited, int $line): ar return $members; } + /** + * Reconcile the supplied type arguments against an alias's parameters, filling missing + * trailing arguments from the parameters' defaults. A default may reference an earlier + * parameter (`B = A`), so each is substituted with the arguments already positioned. The + * required (default-less) parameters form a prefix (enforced by `parseTypeParamList`), so a + * valid supply count is `required <= given <= total`; anything else is `xphp.alias_arity`. + * + * @param array{params:list, body:list} $entry + * @param list $expandedArgs + * @return list + */ + private function padAliasArgs(string $fqn, array $entry, array $expandedArgs, int $line): array + { + $total = count($entry['params']); + $required = 0; + foreach ($entry['params'] as $param) { + if ($param['default'] === null) { + $required++; + } + } + $given = count($expandedArgs); + if ($given < $required || $given > $total) { + // @infection-ignore-all CastString -- $total is interpolated into the message + // either way; the cast only keeps both ternary branches typed `string`. + $expected = $required === $total + ? (string) $total + : "between {$required} and {$total}"; + throw new XphpParseException( + "Type alias `{$fqn}` expects {$expected} type argument(s), {$given} given.", + $line, + XphpSourceParser::CODE_ALIAS_ARITY, + ); + } + $defaults = $this->resolveAliasDefaults($fqn, $entry); + $paramNames = array_column($entry['params'], 'name'); + $padded = $expandedArgs; + for ($i = $given; $i < $total; $i++) { + $subst = []; + foreach ($padded as $k => $arg) { + $subst[$paramNames[$k]] = $arg; + } + // @infection-ignore-all CoalesceRemoval -- indices [$given,$total) are exactly the + // trailing params, every one of which has a default (required params form a prefix), + // so $defaults[$i] is never null here; the coalesce is a defensive floor. + $default = $defaults[$i] ?? throw new \LogicException('padded slot without a default'); + $padded[] = self::substituteTypeRef($default, $subst); + } + return $padded; + } + /** * Resolve an alias's raw body against the current namespace context, with the alias's own * type parameters pushed so `A` / `B` become type-param references rather than qualified * class names. Cached per alias FQN. * - * @param array{paramNames:list, body:list} $entry + * @param array{params:list, body:list} $entry * @return list */ private function resolveAliasBody(string $fqn, array $entry): array @@ -4214,12 +4265,40 @@ private function resolveAliasBody(string $fqn, array $entry): array // exact prior scope stack — so the alias's params never leak into later resolution. // Restore by saved-copy assignment (not a pop) so the restore is exact and unconditional. $saved = $this->typeParamStack; - $this->typeParamStack[] = $entry['paramNames']; + $this->typeParamStack[] = array_column($entry['params'], 'name'); $resolved = array_map(fn (TypeRef $m): TypeRef => $this->resolveTypeRef($m), $entry['body']); $this->typeParamStack = $saved; return $this->aliasBodyCache[$fqn] = $resolved; } + /** + * Resolve an alias's raw parameter DEFAULTS against the current namespace context, with the + * alias's own parameters in scope so a default that references an earlier param (`B = A`) + * resolves to a type-param leaf. Returns one entry per parameter, aligned by position: + * the resolved default TypeRef, or null where the parameter has no default. Cached per FQN. + * + * @param array{params:list, body:list} $entry + * @return list + */ + private function resolveAliasDefaults(string $fqn, array $entry): array + { + // @infection-ignore-all ReturnRemoval -- the cache is an optimization; resolveTypeRef + // is deterministic for a fixed context, so re-resolving on a cache miss is equivalent. + if (isset($this->aliasDefaultsCache[$fqn])) { + return $this->aliasDefaultsCache[$fqn]; + } + $saved = $this->typeParamStack; + $this->typeParamStack[] = array_column($entry['params'], 'name'); + $resolved = array_map( + fn (array $param): ?TypeRef => $param['default'] === null + ? null + : $this->resolveTypeRef($param['default']), + $entry['params'], + ); + $this->typeParamStack = $saved; + return $this->aliasDefaultsCache[$fqn] = $resolved; + } + /** * Replace type-parameter leaves in a resolved TypeRef tree using a name → concrete map. * diff --git a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php index ab4f3ed0..18d370e0 100644 --- a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php +++ b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php @@ -261,6 +261,57 @@ public function testAliasArityMismatchIsRejectedInBothModes(): void $this->assertCompileThrows($files, 'expects 2 type argument(s), 1 given'); } + public function testAliasParameterDefaultReferencingAnEarlierParameterIsFilled(): void + { + // `type P` used as `P` fills the omitted B with A (= int), so it specializes to + // the SAME Dict as the explicit `P`, and NOT the same as `P`. The + // specialization hash is non-deterministic, but equality between two emitted FQNs is exact. + $use = self::read($this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => " = Dict;\nclass C {\n public function omitted(): P { return new Dict::(1, 2); }\n public function explicitSame(): P { return new Dict::(1, 2); }\n public function explicitDiff(): P { return new Dict::(1, 'x'); }\n}\n", + ]), 'Use.php'); + + self::assertSame(self::specFqn($use, 'explicitSame'), self::specFqn($use, 'omitted'), 'P fills B = A = int, matching P'); + self::assertNotSame(self::specFqn($use, 'explicitDiff'), self::specFqn($use, 'omitted'), 'P is not P'); + } + + public function testAliasParameterConcreteDefaultIsFilled(): void + { + // A concrete (non-param-referencing) default: `type Q` used as `Q` fills B + // with string, matching the explicit `Q` and differing from `Q`. + $use = self::read($this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => " = Dict;\nclass C {\n public function omitted(): Q { return new Dict::(1, 'x'); }\n public function explicitSame(): Q { return new Dict::(1, 'x'); }\n public function explicitDiff(): Q { return new Dict::(1, 2); }\n}\n", + ]), 'Use.php'); + + self::assertSame(self::specFqn($use, 'explicitSame'), self::specFqn($use, 'omitted'), 'Q fills B = string, matching Q'); + self::assertNotSame(self::specFqn($use, 'explicitDiff'), self::specFqn($use, 'omitted'), 'Q is not Q'); + } + + public function testAliasDefaultChainFillsTransitively(): void + { + // A default may reference an earlier param that is itself defaulted: `P` used + // as `P` fills B = A = int, then C = B = int — the same specialization as `P`. + $use = self::read($this->compile([ + 'Lib.xphp' => self::LIB, + 'Use.xphp' => " { public function __construct(public X \$x, public Y \$y, public Z \$z) {} }\ntype P = Trip;\nclass C {\n public function omitted(): P { return new Trip::(1, 2, 3); }\n public function explicitFull(): P { return new Trip::(1, 2, 3); }\n}\n", + ]), 'Use.php'); + + self::assertSame(self::specFqn($use, 'explicitFull'), self::specFqn($use, 'omitted'), 'P fills B = A = int then C = B = int'); + } + + public function testAliasArityRangeMessageAppearsOnlyWithDefaults(): void + { + // With a default present the valid arity is a RANGE (required..total); too many args reports + // the "between R and N" form. (A no-default alias keeps the exact "expects N" form — pinned by + // testAliasArityMismatchIsRejectedInBothModes.) + $files = [ + 'C.xphp' => " = Dict;\nclass Dict { public function __construct(public K \$k, public V \$v) {} }\nfunction f(): P { return new Dict::(1, 2); }\n", + ]; + self::assertRejected($this->check($files), XphpSourceParser::CODE_ALIAS_ARITY, 'expects between 1 and 2 type argument(s), 3 given'); + $this->assertCompileThrows($files, 'expects between 1 and 2 type argument(s), 3 given'); + } + public function testUnsupportedAliasBodyIsRejectedInBothModes(): void { // An intersection (and DNF / closure) body is recognized (stripped) but rejected with a clear @@ -403,6 +454,21 @@ private static function read(string $dir, string $file): string return is_file($path) ? (file_get_contents($path) ?: '') : ''; } + /** + * The emitted specialization FQN (`\XPHP\Generated\…`) a given method returns. The hash is + * non-deterministic, so callers compare two of these for equality rather than asserting a literal. + */ + private static function specFqn(string $emitted, string $method): string + { + self::assertSame( + 1, + preg_match('/function ' . preg_quote($method, '/') . '\(\): (\\\\XPHP\\\\Generated\\\\[^\s{]+)/', $emitted, $m), + "method {$method}() specialization not found in emitted source", + ); + + return $m[1]; + } + private static function rrmdir(string $dir): void { if (!is_dir($dir)) { From d97d702f5be586bc80ddcf56da284ca2e39f31bb Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 30 Jul 2026 23:28:59 +0000 Subject: [PATCH 37/46] feat(monomorphize): enforce generic type-alias parameter bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A type alias's parameters may declare bounds (`type B = Bag;`), previously parsed and dropped. Enforce them: because alias expansion runs per file before the whole-program hierarchy exists, each used bounded alias records an AliasBoundObligation (its resolved parameters + concrete padded arguments + use-site location), collected across files and verified once the hierarchy is built by AliasBoundValidator via a new Registry::checkAliasBounds — the same check a class instantiation runs, so a violation surfaces as an identical xphp.bound_violation (thrown in compile, collected in check). An argument whose top level is a type parameter (`B` inside `class C`) is skipped (absent from the hierarchy, it would be spuriously rejected); a concrete head over a type-param inner (`Coll`) is checked, since bounds erase generic arguments. Only file-local generic aliases reach enforcement — a cross-file generic-alias use is a separate unsupported case that hard-errors as an undefined template — so a captured bound always resolves in the context it was declared, with no cross-file misresolution. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/AliasBoundObligation.php | 30 +++++ .../AliasBoundObligationCollector.php | 34 +++++ .../Monomorphize/AliasBoundValidator.php | 33 +++++ src/Transpiler/Monomorphize/Compiler.php | 16 ++- src/Transpiler/Monomorphize/Registry.php | 28 +++++ .../Monomorphize/XphpSourceParser.php | 116 ++++++++++++----- .../Monomorphize/TypeAliasIntegrationTest.php | 117 ++++++++++++++++++ 7 files changed, 341 insertions(+), 33 deletions(-) create mode 100644 src/Transpiler/Monomorphize/AliasBoundObligation.php create mode 100644 src/Transpiler/Monomorphize/AliasBoundObligationCollector.php create mode 100644 src/Transpiler/Monomorphize/AliasBoundValidator.php diff --git a/src/Transpiler/Monomorphize/AliasBoundObligation.php b/src/Transpiler/Monomorphize/AliasBoundObligation.php new file mode 100644 index 00000000..ec5d91ee --- /dev/null +++ b/src/Transpiler/Monomorphize/AliasBoundObligation.php @@ -0,0 +1,30 @@ + $typeParams the alias's resolved parameters (name + bound), in order + * @param list $args the concrete, padded type arguments supplied at the use site + */ + public function __construct( + public array $typeParams, + public array $args, + public string $label, + public SourceLocation $location, + ) { + } +} diff --git a/src/Transpiler/Monomorphize/AliasBoundObligationCollector.php b/src/Transpiler/Monomorphize/AliasBoundObligationCollector.php new file mode 100644 index 00000000..6b98262c --- /dev/null +++ b/src/Transpiler/Monomorphize/AliasBoundObligationCollector.php @@ -0,0 +1,34 @@ + */ + private array $obligations = []; + + /** + * @param list $typeParams + * @param list $args + */ + public function add(array $typeParams, array $args, string $label, SourceLocation $location): void + { + $this->obligations[] = new AliasBoundObligation($typeParams, $args, $label, $location); + } + + /** @return list */ + public function all(): array + { + return $this->obligations; + } +} diff --git a/src/Transpiler/Monomorphize/AliasBoundValidator.php b/src/Transpiler/Monomorphize/AliasBoundValidator.php new file mode 100644 index 00000000..584f6711 --- /dev/null +++ b/src/Transpiler/Monomorphize/AliasBoundValidator.php @@ -0,0 +1,33 @@ +all() as $obligation) { + Registry::checkAliasBounds( + $obligation->typeParams, + $obligation->args, + $hierarchy, + $obligation->label, + $diagnostics, + $obligation->location, + ); + } + } +} diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index a9c4dbe2..b68e9ad1 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -69,7 +69,8 @@ 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 = $this->parseAll($sources); + $aliasBoundObligations = new AliasBoundObligationCollector(); + $astPerFile = $this->parseAll($sources, $aliasBoundObligations); $hierarchy = TypeHierarchy::fromAstPerFile($astPerFile); $registry = new Registry($this->hashLength, $hierarchy); @@ -126,6 +127,9 @@ public function compile( // reaches emission as broken PHP. $registry->validateUndeclaredTypeParameters(); $registry->validateDefaultsAgainstBounds(); + // Enforce type-alias parameter bounds now that the hierarchy exists (the obligations were + // captured during parse, before it did) — compile mode has no collector, so a violation throws. + AliasBoundValidator::validate($aliasBoundObligations, $hierarchy); // 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 { f(): Container }` where @@ -457,6 +461,7 @@ private function specializeToFixedPoint( public function check(FilepathArray $sources): DiagnosticCollector { $diagnostics = new DiagnosticCollector(); + $aliasBoundObligations = new AliasBoundObligationCollector(); // Read every source up front — OUTSIDE the try so an I/O failure surfaces as itself, not a // mislabeled "parse error" — then merge a whole-program alias table so a cross-file alias use // resolves. Only parsing is treated as a per-file, recoverable diagnostic. @@ -468,7 +473,7 @@ public function check(FilepathArray $sources): DiagnosticCollector $astPerFile = []; foreach ($contents as $filepath => $content) { try { - $astPerFile[$filepath] = $this->sourceParser->parse($content, $globalAliases); + $astPerFile[$filepath] = $this->sourceParser->parse($content, $globalAliases, $filepath, $aliasBoundObligations); } catch (PhpParserError $e) { $line = $e->getStartLine(); $diagnostics->add(new Diagnostic( @@ -528,6 +533,9 @@ public function check(FilepathArray $sources): DiagnosticCollector $registry->validateUndeclaredTypeParameters(); UndeclaredTypeParameterValidator::assertMethodLevel($astPerFile, $hierarchy, $diagnostics); $registry->validateDefaultsAgainstBounds(); + // Enforce type-alias parameter bounds (obligations captured during parse) now the hierarchy + // exists; check mode collects each violation as an xphp.bound_violation and continues. + AliasBoundValidator::validate($aliasBoundObligations, $hierarchy, $diagnostics); $registry->validateInnerVariance(); // Closure-signature conformance at the statically-visible literal site // (a `Closure(...)` return handing back a closure literal). In @@ -582,7 +590,7 @@ public function check(FilepathArray $sources): DiagnosticCollector * * @return array> */ - private function parseAll(FilepathArray $sources): array + private function parseAll(FilepathArray $sources, ?AliasBoundObligationCollector $obligations = null): array { $contents = []; foreach ($sources->filepaths as $filepath) { @@ -592,7 +600,7 @@ private function parseAll(FilepathArray $sources): array $astPerFile = []; foreach ($contents as $filepath => $content) { - $astPerFile[$filepath] = $this->sourceParser->parse($content, $globalAliases); + $astPerFile[$filepath] = $this->sourceParser->parse($content, $globalAliases, $filepath, $obligations); } return $astPerFile; diff --git a/src/Transpiler/Monomorphize/Registry.php b/src/Transpiler/Monomorphize/Registry.php index 6e55b18b..67e710fc 100644 --- a/src/Transpiler/Monomorphize/Registry.php +++ b/src/Transpiler/Monomorphize/Registry.php @@ -748,6 +748,34 @@ private static function varianceEdgeUnprovableMessage( ); } + /** + * Bound-check a used type alias's parameters against its concrete arguments — the same check + * {@see validateBounds} runs for a class instantiation, grounding any sibling-referencing bound + * (``) against the supplied args first. Exposed statically so the post-hierarchy + * {@see AliasBoundValidator} pass reports through the identical `checkBounds` seam (a violation + * surfaces as the same `xphp.bound_violation`). + * + * @param list $typeParams + * @param list $args + */ + public static function checkAliasBounds( + array $typeParams, + array $args, + TypeHierarchy $hierarchy, + string $label, + ?DiagnosticCollector $diagnostics = null, + ?SourceLocation $callSite = null, + ): void { + self::checkBounds( + self::groundSiblingBounds($typeParams, $args), + $args, + $hierarchy, + $label, + $diagnostics, + $callSite, + ); + } + /** * Reusable bound check for any (typeParams, concreteArgs) pair against a hierarchy. * diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 43470d62..ac0d0cfd 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -15,6 +15,7 @@ use PhpParser\Parser; use PhpToken; use RuntimeException; +use XPHP\Diagnostics\SourceLocation; /** * Parses .xphp source text into an AST with generic metadata, supporting @@ -147,11 +148,15 @@ public function __construct(private readonly Parser $parser) * @param array, body:list}>|null $externalAliases * a whole-program alias table (from {@see aliasTableOf} across every source) used for * cross-file expansion; null keeps aliases file-local (standalone parse / LSP). + * @param ?string $filepath the source file, threaded only so a captured alias-bound obligation + * can carry an accurate SourceLocation; null on the standalone parse path. + * @param ?AliasBoundObligationCollector $obligations sink for alias parameter-bound obligations, + * verified after the hierarchy is built; null (inert) on the standalone parse path. * @return list */ - public function parse(string $source, ?array $externalAliases = null): array + public function parse(string $source, ?array $externalAliases = null, ?string $filepath = null, ?AliasBoundObligationCollector $obligations = null): array { - return $this->parseWithMap($source, $externalAliases)[0]; + return $this->parseWithMap($source, $externalAliases, $filepath, $obligations)[0]; } /** @@ -189,9 +194,10 @@ public function aliasTableOf(string $source): array * (the common case for files without `T[]` array-suffix sugar). * * @param array, body:list}>|null $externalAliases + * @param ?AliasBoundObligationCollector $obligations sink for alias parameter-bound obligations (null = inert) * @return array{0: list, 1: ByteOffsetMap} */ - public function parseWithMap(string $source, ?array $externalAliases = null): array + public function parseWithMap(string $source, ?array $externalAliases = null, ?string $filepath = null, ?AliasBoundObligationCollector $obligations = null): array { [$classMarkers, $nameMarkers, $methodMarkers, $cleanedSource, $byteOffsetMap, $closureMarkers, $aliasMarkers] = $this->scanAndStrip($source); @@ -211,7 +217,7 @@ public function parseWithMap(string $source, ?array $externalAliases = null): ar } /** @var list $ast — nikic's parse() returns array; runtime keys are always 0..N-1. */ - $unbound = $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap, $aliasMarkers, $externalAliases); + $unbound = $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap, $aliasMarkers, $externalAliases, $filepath, $obligations); // @infection-ignore-all — defensive backstop, unreachable from valid input by // construction (see unboundDeclarationMarkerMessage): no test can reach a // mutant here. The message builder is pinned by direct unit tests; this @@ -3041,8 +3047,9 @@ private static function collectClassLikeFqns(array $ast): array * @param list $closureMarkers * @param list, body:?list, bytePosition:int, line:int}> $aliasMarkers * @param array, body:list}>|null $externalAliases + * @param ?AliasBoundObligationCollector $obligations sink for alias parameter-bound obligations (null = inert) */ - private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers, array $closureMarkers, ByteOffsetMap $byteOffsetMap, array $aliasMarkers, ?array $externalAliases = null): ?string + private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers, array $closureMarkers, ByteOffsetMap $byteOffsetMap, array $aliasMarkers, ?array $externalAliases = null, ?string $filepath = null, ?AliasBoundObligationCollector $obligations = null): ?string { // buildAliasTable runs the per-file rejections (same-file duplicate / class-collision / // unsupported body) regardless; a whole-program table, when injected, is what expansion @@ -3054,7 +3061,7 @@ private function resolveAndAttach(array $ast, array $classMarkers, array $nameMa /** * @phpstan-import-type BoundDict from XphpSourceParser */ - class($classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap, $aliasTable) extends NodeVisitorAbstract { + class($classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap, $aliasTable, $filepath, $obligations) extends NodeVisitorAbstract { private NamespaceContext $ctx; /** @var list> stack of enclosing type-param scopes */ private array $typeParamStack = []; @@ -3069,8 +3076,10 @@ class($classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetM * @param array}> $nameMarkers * @param array}> $methodMarkers * @param array $closureMarkers - * @param array, body:list}> $aliasTable file-local - * type aliases keyed by FQN; body is the raw (unresolved) TypeRef. + * @param array, body:list}> $aliasTable the + * aliases available for expansion (whole-program when injected) keyed by FQN; body is the raw (unresolved) TypeRef. + * @param ?string $filepath the source file, for a captured obligation's SourceLocation; null on the standalone parse path + * @param ?AliasBoundObligationCollector $obligations sink for alias parameter-bound obligations; null (inert) on the standalone parse path */ public function __construct( private array $classMarkers, @@ -3079,6 +3088,8 @@ public function __construct( private array $closureMarkers, private ByteOffsetMap $byteOffsetMap, private array $aliasTable, + private ?string $filepath, + private ?AliasBoundObligationCollector $obligations, ) { $this->ctx = new NamespaceContext(); } @@ -3092,12 +3103,13 @@ public function __construct( private array $aliasBodyCache = []; /** - * Cache of resolved alias parameter defaults keyed by alias FQN — aligned by position to - * the alias's params, null where a param has no default. Resolved once, like the body. + * Cache of resolved alias parameters keyed by alias FQN — each raw param entry resolved + * (against the use-site context, with the alias's params in scope) to a TypeParam carrying + * its bound and default. Feeds both default-padding and bound enforcement. Resolved once. * - * @var array> + * @var array> */ - private array $aliasDefaultsCache = []; + private array $aliasParamsCache = []; // Returns a replacement Node when a type-alias use is expanded in place (the traverser // swaps it into the parent slot); null in every other case leaves the node untouched. @@ -4182,6 +4194,7 @@ private function expandAliasToUnion(TypeRef $ref, array $visited, int $line): ar ); } $paddedArgs = $this->padAliasArgs($ref->name, $entry, $expandedArgs, $line); + $this->captureAliasBoundObligation($ref->name, $entry, $paddedArgs, $line); $subst = []; foreach (array_column($entry['params'], 'name') as $k => $paramName) { $subst[$paramName] = $paddedArgs[$k]; @@ -4196,6 +4209,47 @@ private function expandAliasToUnion(TypeRef $ref, array $visited, int $line): ar return $members; } + /** + * Record a deferred bound-check for a used alias whose parameters declare bounds, to be + * verified once the whole-program hierarchy exists ({@see AliasBoundValidator}). Captured + * only when a collector is threaded in (the compile/check path — inert for standalone / LSP + * parse) and every supplied argument is top-level ground: a bare type-param argument + * (`B` inside `class C`) is absent from the hierarchy and would be spuriously + * rejected, so it is skipped, whereas a concrete head over a type-param inner (`Box`) IS + * captured (bounds erase generic arguments). + * + * Only a generic alias has parameters, hence bounds; and a generic alias only expands where + * it is FILE-LOCAL (a cross-file generic-alias use is a separate unsupported case that + * hard-errors as an undefined template, never reaching here). So a captured bound always + * resolves in the same namespace it was declared in — no cross-file misresolution. When + * cross-file generic aliases are supported, that resolution context must be revisited. + * + * @param array{params:list, body:list} $entry + * @param list $paddedArgs + */ + private function captureAliasBoundObligation(string $fqn, array $entry, array $paddedArgs, int $line): void + { + if ($this->obligations === null) { + return; + } + // A top-level type-param argument (`B` in `class C`) is absent from the hierarchy + // and would be spuriously rejected; skip the whole obligation. A concrete head over a + // type-param inner (`Coll`) is kept — bounds erase generic arguments. (An alias with + // no bounds is captured harmlessly: checkBounds is a no-op for a param without a bound, + // so gating on "has a bound" would be an unobservable optimization.) + foreach ($paddedArgs as $arg) { + if ($arg->isTypeParam) { + return; + } + } + $this->obligations->add( + $this->resolveAliasParams($fqn, $entry), + $paddedArgs, + "type alias `{$fqn}`", + new SourceLocation($this->filepath ?? '', $line), + ); + } + /** * Reconcile the supplied type arguments against an alias's parameters, filling missing * trailing arguments from the parameters' defaults. A default may reference an earlier @@ -4229,7 +4283,7 @@ private function padAliasArgs(string $fqn, array $entry, array $expandedArgs, in XphpSourceParser::CODE_ALIAS_ARITY, ); } - $defaults = $this->resolveAliasDefaults($fqn, $entry); + $params = $this->resolveAliasParams($fqn, $entry); $paramNames = array_column($entry['params'], 'name'); $padded = $expandedArgs; for ($i = $given; $i < $total; $i++) { @@ -4239,8 +4293,8 @@ private function padAliasArgs(string $fqn, array $entry, array $expandedArgs, in } // @infection-ignore-all CoalesceRemoval -- indices [$given,$total) are exactly the // trailing params, every one of which has a default (required params form a prefix), - // so $defaults[$i] is never null here; the coalesce is a defensive floor. - $default = $defaults[$i] ?? throw new \LogicException('padded slot without a default'); + // so $params[$i]->default is never null here; the coalesce is a defensive floor. + $default = $params[$i]->default ?? throw new \LogicException('padded slot without a default'); $padded[] = self::substituteTypeRef($default, $subst); } return $padded; @@ -4272,31 +4326,35 @@ private function resolveAliasBody(string $fqn, array $entry): array } /** - * Resolve an alias's raw parameter DEFAULTS against the current namespace context, with the - * alias's own parameters in scope so a default that references an earlier param (`B = A`) - * resolves to a type-param leaf. Returns one entry per parameter, aligned by position: - * the resolved default TypeRef, or null where the parameter has no default. Cached per FQN. + * Resolve an alias's raw parameter entries against the current namespace context, with the + * alias's own parameters in scope so a bound / default that references a param (`T : A`, + * `B = A`) resolves to a type-param leaf. Returns one TypeParam per parameter, in order, + * carrying the resolved bound and default. Cached per FQN; feeds both default-padding + * (`padAliasArgs`) and bound enforcement (`captureAliasBoundObligation`). * * @param array{params:list, body:list} $entry - * @return list + * @return list */ - private function resolveAliasDefaults(string $fqn, array $entry): array + private function resolveAliasParams(string $fqn, array $entry): array { - // @infection-ignore-all ReturnRemoval -- the cache is an optimization; resolveTypeRef - // is deterministic for a fixed context, so re-resolving on a cache miss is equivalent. - if (isset($this->aliasDefaultsCache[$fqn])) { - return $this->aliasDefaultsCache[$fqn]; + // @infection-ignore-all ReturnRemoval -- the cache is an optimization; resolution is + // deterministic for a fixed context, so re-resolving on a cache miss is equivalent. + if (isset($this->aliasParamsCache[$fqn])) { + return $this->aliasParamsCache[$fqn]; } $saved = $this->typeParamStack; $this->typeParamStack[] = array_column($entry['params'], 'name'); $resolved = array_map( - fn (array $param): ?TypeRef => $param['default'] === null - ? null - : $this->resolveTypeRef($param['default']), + fn (array $param): TypeParam => new TypeParam( + $param['name'], + $this->buildBoundExpr($param), + $this->buildDefault($param), + $param['variance'], + ), $entry['params'], ); $this->typeParamStack = $saved; - return $this->aliasDefaultsCache[$fqn] = $resolved; + return $this->aliasParamsCache[$fqn] = $resolved; } /** diff --git a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php index 18d370e0..617051f5 100644 --- a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php +++ b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php @@ -312,6 +312,106 @@ public function testAliasArityRangeMessageAppearsOnlyWithDefaults(): void $this->assertCompileThrows($files, 'expects between 1 and 2 type argument(s), 3 given'); } + public function testAliasParameterBoundViolationIsRejectedInBothModes(): void + { + // A ground argument that does not satisfy an alias parameter's bound is a loud error, routed + // through the SAME check a class instantiation uses — an identical `xphp.bound_violation` with + // the "type alias" label. `check` collects it; `compile` throws (a RuntimeException, not the + // parse exception, since the check runs post-hierarchy). + $files = [ + 'C.xphp' => " { public function __construct(public T \$i) {} }\ntype B = Bag;\nfunction f(): B { return new Bag::(1); }\n", + ]; + $collector = $this->check($files); + self::assertRejected($collector, Registry::CODE_BOUND_VIOLATION, 'Generic bound violated while instantiating type alias `App\\B`'); + // The diagnostic points at the USE-site file — a captured obligation carries a real location. + $violations = array_values(array_filter($collector->all(), static fn ($d): bool => $d->code === Registry::CODE_BOUND_VIOLATION)); + self::assertStringEndsWith('C.xphp', $violations[0]->location?->file ?? ''); + $this->assertCompileThrowsRuntime($files, '"int" does not extend/implement "App\\Named"'); + } + + public function testAliasParameterBoundSatisfiedByAGroundArgumentCompiles(): void + { + // A ground argument that satisfies the bound compiles cleanly — no false positive. + $use = self::read($this->compile([ + 'C.xphp' => " { public function __construct(public T \$i) {} }\ntype B = Bag;\nclass C { public function f(): B { return new Bag::(new Widget()); } }\n", + ]), 'C.php'); + + self::assertStringContainsString('\\XPHP\\Generated\\App\\Bag\\', self::specFqn($use, 'f')); + } + + public function testAliasParameterBoundOnATopLevelTypeParameterArgumentIsSkipped(): void + { + // `B` inside `class G`: the argument's top level is a type parameter, absent from the + // hierarchy, so checking it would spuriously reject. It is skipped (the alias erases to + // `Bag`, whose own bounds — none here — still apply when G specializes). No false positive. + $dist = $this->compile([ + 'C.xphp' => " { public function __construct(public T \$i) {} }\ntype B = Bag;\nclass G { public function __construct(public X \$x) {} public function f(): B { return new Bag::(\$this->x); } }\nclass H { public function make(): G { return new G::(1); } }\n", + ]); + + self::assertStringContainsString('class', self::read($dist, 'C.php')); + } + + public function testAliasParameterBoundChecksAConcreteHeadOverATypeParameterInnerArgument(): void + { + // `B>` inside `class G`: the argument's TOP LEVEL is the concrete `Coll` (not a type + // parameter), so it is checked even though its inner arg is a type parameter — bounds erase + // generic arguments. `Coll` does not implement `Named`, so this is a violation, not a skip. + $files = [ + 'C.xphp' => " {}\nclass Bag { public function __construct(public T \$i) {} }\ntype B = Bag;\nclass G { public function f(): B> { throw new \\Exception(); } }\nclass H { public function make(): G { return new G::(); } }\n", + ]; + self::assertRejected($this->check($files), Registry::CODE_BOUND_VIOLATION, 'does not extend/implement "App\\Named"'); + } + + public function testAliasParameterBoundWithAnUnknownGroundClassIsRejected(): void + { + // A ground class the hierarchy was not built from (e.g. a vendor class) is an UNKNOWN verdict, + // which — exactly as for class generics — is rejected (the compiler cannot prove the bound), so + // no knowably-unprovable specialization is emitted silently. + $files = [ + 'C.xphp' => " { public function __construct(public T \$i) {} }\ntype B = Bag;\nfunction f(): B<\\DateTime> { return new Bag::<\\DateTime>(new \\DateTime()); }\n", + ]; + self::assertRejected($this->check($files), Registry::CODE_BOUND_VIOLATION, 'is not in the source set the hierarchy was built from'); + } + + public function testAliasParameterSiblingReferencingBoundIsGroundedAndChecked(): void + { + // A bound referencing an earlier sibling parameter (``) is grounded against the + // supplied args before checking — `Pair` fails (int is not Named) while + // `Pair` passes (Widget implements Named). + $bad = [ + 'C.xphp' => " {}\ntype Pair = Two;\nfunction f(): Pair { throw new \\Exception(); }\n", + ]; + self::assertRejected($this->check($bad), Registry::CODE_BOUND_VIOLATION, 'does not extend/implement "App\\Named"'); + + $good = self::read($this->compile([ + 'C.xphp' => " {}\ntype Pair = Two;\nclass C { public function f(): Pair { throw new \\Exception(); } }\n", + ]), 'C.php'); + self::assertStringContainsString('\\XPHP\\Generated\\App\\Two\\', self::specFqn($good, 'f')); + } + + public function testAliasParameterBoundIsReportedPerGroundUseSite(): void + { + // Obligations are per use site (no FQN de-dup like the Registry): two ground violating uses of + // the same bounded alias yield two collected diagnostics in check mode. + $files = [ + 'C.xphp' => " { public function __construct(public T \$i) {} }\ntype B = Bag;\nfunction f(): B { return new Bag::(1); }\nfunction g(): B { return new Bag::('x'); }\n", + ]; + $violations = array_filter($this->check($files)->all(), static fn ($d): bool => $d->code === Registry::CODE_BOUND_VIOLATION); + self::assertCount(2, $violations); + } + + public function testABadDefaultOnAnUnusedAliasIsNotChecked(): void + { + // Obligations are captured only where an alias is USED; an alias declared with a default that + // would violate its own bound but never instantiated emits nothing (unlike a class template, + // which is checked at declaration). Documented divergence, not a bug: an unused alias is inert. + $dist = $this->compile([ + 'C.xphp' => " { public function __construct(public T \$i) {} }\ntype B = Bag;\nclass C { public function unrelated(): int { return 1; } }\n", + ]); + + self::assertStringContainsString('function unrelated(): int', self::read($dist, 'C.php')); + } + public function testUnsupportedAliasBodyIsRejectedInBothModes(): void { // An intersection (and DNF / closure) body is recognized (stripped) but rejected with a clear @@ -389,6 +489,23 @@ private function assertCompileThrows(array $files, string $needle): void } } + /** + * Like {@see assertCompileThrows}, but for a violation raised AFTER parsing (an alias parameter + * bound, checked once the hierarchy exists) — which throws a plain RuntimeException, not the parse + * exception. + * + * @param array $files + */ + private function assertCompileThrowsRuntime(array $files, string $needle): void + { + try { + $this->compile($files); + self::fail('compile must reject the alias bound violation loudly'); + } catch (\RuntimeException $e) { + self::assertStringContainsString($needle, $e->getMessage()); + } + } + private const LIB = <<<'PHP' Date: Thu, 30 Jul 2026 23:30:47 +0000 Subject: [PATCH 38/46] docs(type-aliases): document parameter defaults and bounds Record the two new generic-alias parameter capabilities: defaults (`type P`, trailing arguments may be omitted) and enforced bounds (`type B`, a violating argument is xphp.bound_violation). Update the syntax tour's Rules, the roadmap Shipped entry, and the CHANGELOG, and note in the caveat that a generic alias is file-local (a non-generic alias is cross-file). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 17 ++++++++++------- docs/caveats.md | 20 +++++++++++++++----- docs/roadmap.md | 12 ++++++++---- docs/syntax/type-aliases.md | 19 +++++++++++++++---- 4 files changed, 48 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56b65fd9..4157bb4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,14 +18,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 a single head expands in every type position (incl. as a generic argument, `Bag`), while a union/nullable expands as the whole type of a parameter, property, return, or class-constant slot. Aliases compose (nested and - concrete-instantiation, `type UserMap = Pair`), and an alias declared - in one file is usable in another (**cross-file**, whole-program). A cyclic - (`xphp.alias_cycle`), arity-mismatched (`xphp.alias_arity`), class-colliding - (`xphp.alias_class_collision`), duplicate (`xphp.alias_duplicate`), + concrete-instantiation, `type UserMap = Pair`); parameters carry + **defaults** (`type P` — a use may omit trailing defaulted arguments) + and **bounds** (`type B` — an argument that violates the bound is a + compile error), like a generic class. A non-generic alias declared in one file is + usable in another (**cross-file**, whole-program); a generic alias is file-local. + A cyclic (`xphp.alias_cycle`), arity-mismatched (`xphp.alias_arity`), + class-colliding (`xphp.alias_class_collision`), duplicate (`xphp.alias_duplicate`), unsupported-body (`xphp.alias_unsupported_body` — intersection / DNF / closure), - or compound-in-non-slot (`xphp.alias_compound_in_non_slot`) alias is a loud error - in both `xphp compile` and `xphp check`. See - [type aliases](docs/syntax/type-aliases.md). + compound-in-non-slot (`xphp.alias_compound_in_non_slot`), or bound-violating + (`xphp.bound_violation`) alias is a loud error in both `xphp compile` and + `xphp check`. See [type aliases](docs/syntax/type-aliases.md). - **Type-argument inference (optional turbofish).** A generic call or `new` whose type parameters are determined by the argument values no longer needs the `::<>` turbofish: `identity(5)` infers `identity::`, `new Box($product)` infers diff --git a/docs/caveats.md b/docs/caveats.md index 5c8731e2..30594a6c 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -93,8 +93,8 @@ behavior, only makes the type explicit. [Type aliases](syntax/type-aliases.md) are a compile-time substitution. A single head (`Ident`, `Box`), a union (`int|string`), and a nullable (`?Box`) body -are all supported, and an alias declared in one file is usable in another. Three -limits remain. +are all supported; parameters may carry defaults and bounds; and a **non-generic** +alias declared in one file is usable in another. Three limits remain. ### ❌ What doesn't work @@ -109,6 +109,12 @@ function f(Num $n): void {} // ✓ whole param slot function g(Bag $x): void {} // ✗ xphp.alias_compound_in_non_slot — generic argument function h(Num&Extra $x): void {} // ✗ nested in another intersection/union $b = new Num(); // ✗ compound alias in `new` / extends / a bound + +// A GENERIC alias (one with type parameters) is file-local: +// File Types.xphp +type Pair = Dict; +// File Other.xphp — a DIFFERENT file +function f(): Pair { /* … */ } // ✗ Pair is not visible here (generic alias is file-local) ``` Cross-file, an alias colliding with a **class in another file**, or the same alias @@ -123,9 +129,11 @@ lower cleanly into a PHP type node. An intersection or DNF pulls in *distributio anchor, so it is representable only as the whole type of a param / property / return / class-constant slot — anywhere else it is rejected loudly rather than mis-compiled. Cross-file expansion is a whole-program pre-pass that merges each -file's alias table; global duplicate/collision checking across that merge is a -later refinement. These are "make the safe subset solid first" trades, not -permanent design limits. +file's alias table; a *generic* alias use is expanded before that table is +consulted for arguments, so a generic alias resolves only within its own file +(and its declared parameter bounds are enforced there). Global duplicate/collision +checking across the merge is a later refinement. These are "make the safe subset +solid first" trades, not permanent design limits. ### ✅ Workaround @@ -133,6 +141,8 @@ permanent design limits. a named class or interface and alias *that*. - Use a union/nullable alias as the whole type of a slot; write the union directly where you need it as a generic argument or nested in another compound type. +- Declare a generic alias in each file that uses it (a zero-cost substitution), or + reference the underlying generic type directly across files. --- diff --git a/docs/roadmap.md b/docs/roadmap.md index 073cc5ff..85075c72 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -231,11 +231,15 @@ upcoming one. A single head expands in every type position (incl. as a generic argument, `Bag`); a union/nullable expands as the whole type of a slot. Composes with nested and concrete-instantiation aliases. -- **Cross-file**: an alias declared in one file is usable in another - (whole-program alias table). +- Parameters carry **defaults** (`type P` — a use may omit + trailing defaulted arguments) and **bounds** (`type B` — an + argument that violates the bound is a compile error), like a generic class. +- **Cross-file**: a non-generic alias declared in one file is usable in + another (whole-program alias table); a generic alias is file-local. - Cyclic, arity-mismatched, class-colliding, duplicate, unsupported-body - (intersection / DNF / closure), and compound-in-non-slot uses are loud - compile errors in both `compile` and `check`, each with a stable code. + (intersection / DNF / closure), compound-in-non-slot, and + bound-violating uses are loud compile errors in both `compile` and + `check`, each with a stable code. - See the [type aliases](syntax/type-aliases.md) tour and the [body / position limits caveat](caveats.md#type-alias-body-and-position-limits). diff --git a/docs/syntax/type-aliases.md b/docs/syntax/type-aliases.md index dacb3626..c140a6c9 100644 --- a/docs/syntax/type-aliases.md +++ b/docs/syntax/type-aliases.md @@ -70,8 +70,16 @@ no separate code path and no runtime cost. argument (`Bag`), `new`, `extends`, and a bound. A **union / nullable** body expands only as the *whole* type of a parameter, property, return, or class-constant slot (see caveats). -- **Cross-file**: an alias declared in one file is usable in another file - of the same build (the whole program shares one alias table). +- **Parameters** may carry **defaults** and **bounds**, like a generic + class: `type P = Dict;` (a use may omit trailing + defaulted arguments — `P` fills `B = A = int`), and + `type B = Bag;` (a use whose argument does not satisfy the + bound is a compile error, the same `xphp.bound_violation` a class + instantiation raises). +- **Cross-file**: a **non-generic** alias declared in one file is usable in + another of the same build (the whole program shares one alias table). A + **generic** alias (one with type parameters) is **file-local** — use it + in the file that declares it (see caveats). - Aliases compose: an alias body may reference another alias (`type UserMap = Pair`), and an alias may take type parameters used inside its body (`type Pair = Dict>`). @@ -81,8 +89,11 @@ no separate code path and no runtime cost. both `xphp compile` and `xphp check`): - `xphp.alias_cycle` — an alias defined, directly or transitively, in terms of itself (`type A = B; type B = A;`). - - `xphp.alias_arity` — a use whose type-argument count differs from the - alias's parameter count (`type P = …;` used as `P`). + - `xphp.alias_arity` — a use whose type-argument count is outside the + alias's accepted range (`type P = …;` used as `P`; with a + default the range widens — `type P` accepts one or two). + - `xphp.bound_violation` — a use whose argument does not satisfy a + parameter's bound (`type B = …;` used as `B`). - `xphp.alias_class_collision` — an alias whose name collides with a class, interface, or trait of the same name (no silent shadowing). - `xphp.alias_duplicate` — the same alias name declared twice. From 34759a37b8af24efe41e2f150dfd52bbab36aa7b Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 30 Jul 2026 23:43:22 +0000 Subject: [PATCH 39/46] fix(monomorphize): drop a file's alias-bound obligations when its parse aborts In check mode, a file that aborts mid-parse (e.g. an arity error) has its AST dropped from the whole-program hierarchy, but any alias-bound obligation already captured during that file's traversal survived in the shared collector. A VALID bounded-alias use earlier in the same file was then verified against a hierarchy missing that file's types, producing a spurious xphp.bound_violation claiming a type that is declared right there "is not in the source set". Buffer each file's obligations in a per-file collector and absorb them into the shared one only after the file parses cleanly, so a failed file's obligations are discarded with its AST. compile() was unaffected (it aborts before the validator runs), but the fix keeps the two paths consistent. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AliasBoundObligationCollector.php | 13 +++++++++++ src/Transpiler/Monomorphize/Compiler.php | 8 ++++++- .../Monomorphize/TypeAliasIntegrationTest.php | 22 ++++++++++++++++--- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/Transpiler/Monomorphize/AliasBoundObligationCollector.php b/src/Transpiler/Monomorphize/AliasBoundObligationCollector.php index 6b98262c..ed6f2a55 100644 --- a/src/Transpiler/Monomorphize/AliasBoundObligationCollector.php +++ b/src/Transpiler/Monomorphize/AliasBoundObligationCollector.php @@ -26,6 +26,19 @@ public function add(array $typeParams, array $args, string $label, SourceLocatio $this->obligations[] = new AliasBoundObligation($typeParams, $args, $label, $location); } + /** + * Commit another (per-file) collector's obligations into this one. Used so a file's obligations + * are absorbed only after that file has parsed successfully: a file that aborts mid-parse has its + * AST dropped from the hierarchy, so its obligations — which may reference now-absent types — + * must be dropped with it rather than checked against a hierarchy that no longer contains them. + */ + public function absorb(self $other): void + { + foreach ($other->obligations as $obligation) { + $this->obligations[] = $obligation; + } + } + /** @return list */ public function all(): array { diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index b68e9ad1..949a59a5 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -472,8 +472,14 @@ public function check(FilepathArray $sources): DiagnosticCollector $globalAliases = $this->collectGlobalAliases($contents); $astPerFile = []; foreach ($contents as $filepath => $content) { + // Buffer this file's alias-bound obligations and commit them to the shared collector only + // once the file has parsed cleanly — a file that aborts mid-parse is dropped from the + // hierarchy, so its obligations must not be checked against it (they would reference + // now-absent types and mis-report a valid use as a bound violation). + $fileObligations = new AliasBoundObligationCollector(); try { - $astPerFile[$filepath] = $this->sourceParser->parse($content, $globalAliases, $filepath, $aliasBoundObligations); + $astPerFile[$filepath] = $this->sourceParser->parse($content, $globalAliases, $filepath, $fileObligations); + $aliasBoundObligations->absorb($fileObligations); } catch (PhpParserError $e) { $line = $e->getStartLine(); $diagnostics->add(new Diagnostic( diff --git a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php index 617051f5..6e8a785b 100644 --- a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php +++ b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php @@ -405,11 +405,27 @@ public function testABadDefaultOnAnUnusedAliasIsNotChecked(): void // Obligations are captured only where an alias is USED; an alias declared with a default that // would violate its own bound but never instantiated emits nothing (unlike a class template, // which is checked at declaration). Documented divergence, not a bug: an unused alias is inert. - $dist = $this->compile([ + $files = [ 'C.xphp' => " { public function __construct(public T \$i) {} }\ntype B = Bag;\nclass C { public function unrelated(): int { return 1; } }\n", - ]); + ]; + // No diagnostic of any kind — in particular no bound violation for the (never instantiated) + // bad default — and the unrelated code still compiles. + self::assertFalse($this->check($files)->hasErrors(), 'an unused alias with a bad default is inert'); + self::assertStringContainsString('function unrelated(): int', self::read($this->compile($files), 'C.php')); + } - self::assertStringContainsString('function unrelated(): int', self::read($dist, 'C.php')); + public function testAValidBoundedUseIsNotFalselyReportedWhenTheSameFileAbortsParsing(): void + { + // A file that aborts mid-parse (here on an arity error) is dropped from the hierarchy. A VALID + // bounded-alias use earlier in the same file must NOT then be checked against that missing + // hierarchy — its obligation is discarded with the file, so no spurious bound violation for a + // type ("not in the source set") that is in fact declared right there. + $files = [ + 'A.xphp' => " { public function __construct(public T \$i) {} }\nclass Dict { public function __construct(public K \$k, public V \$v) {} }\ntype B = Bag;\ntype P = Dict;\nfunction ok(): B { return new Bag::(new Widget()); }\nfunction bad(): P { return new Dict::(1, 2); }\n", + ]; + $codes = array_map(static fn ($d): string => $d->code, $this->check($files)->all()); + self::assertContains(XphpSourceParser::CODE_ALIAS_ARITY, $codes, 'the real arity error is still reported'); + self::assertNotContains(Registry::CODE_BOUND_VIOLATION, $codes, 'the valid bounded use must not be falsely flagged'); } public function testUnsupportedAliasBodyIsRejectedInBothModes(): void From 79bcc03ea08de6881daad99ef9897d77db5be181 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 31 Jul 2026 08:32:53 +0000 Subject: [PATCH 40/46] fix(monomorphize): expand a type alias used as a parameter bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A parameter bound that names a type alias (`type Named = Face; type B = …`, and likewise `class Box` / a generic method) was resolved by name only, never expanded — so the alias became a phantom class `App\Named` and every argument was rejected as not extending it. Pre-existing for class/method bounds (an xphp.undeclared_type on the phantom); WI-05 exposed it for alias-parameter bounds by enforcing them. Expand an alias leaf in `buildBoundExprNode` exactly as a type position does: a single-head alias becomes that head, a union/nullable alias becomes a union bound (any-of). Reuses the existing expansion (generics, defaults, nested aliases). Guard the newly-reachable recursion — an alias whose own parameter bound refers back to itself — with an in-flight set in `resolveAliasParams`, turning what would be a stack overflow into a clean xphp.alias_cycle. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 47 ++++++++++- .../Monomorphize/TypeAliasIntegrationTest.php | 80 +++++++++++++++++++ 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index ac0d0cfd..7b568b4b 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -3111,6 +3111,16 @@ public function __construct( */ private array $aliasParamsCache = []; + /** + * Alias FQNs whose parameter resolution has begun. Checked only after {@see $aliasParamsCache} + * misses, so a re-entry recorded here (but not yet cached) is an alias resolving through its + * own parameter bound — a self-referential cycle. Not cleared: once cached, the cache check + * short-circuits before this guard, so a lingering flag never yields a false positive. + * + * @var array + */ + private array $aliasParamsInFlight = []; + // Returns a replacement Node when a type-alias use is expanded in place (the traverser // swaps it into the parent slot); null in every other case leaves the node untouched. public function enterNode(Node $node): ?Node @@ -3970,6 +3980,20 @@ private function buildBoundExprNode(array $node): BoundExpr $fqn = $node['isFq'] ? $node['name'] : $this->resolveNameOnly($node['name']); + // If the bound names a type alias, expand it exactly as a type position would, so + // the check runs against the real type: a single-head alias (`Named = Face`) + // becomes that head, a union / nullable alias (`Num = int|string`) becomes a union + // bound (any-of). Without this the alias name is a phantom class and every argument + // is wrongly rejected. Reaches class-, method-, and alias-parameter bounds alike. + if (isset($this->aliasTable[$fqn])) { + // @infection-ignore-all IncrementInteger -- buildBoundExprNode carries no source + // line; a cycle/arity error while expanding a *bound* alias is reported at the + // check-mode line-1 fallback whether the seed is 0 or 1, so the value is inert. + $members = $this->expandAliasToUnion(new TypeRef($fqn, $resolvedArgs), [], 0); + return count($members) === 1 + ? new BoundLeaf($members[0]) + : new BoundUnion(...array_map(static fn (TypeRef $m): BoundLeaf => new BoundLeaf($m), $members)); + } $suspect = !$node['isFq'] && $this->isSuspectUndeclared($node['name']); return new BoundLeaf(new TypeRef($fqn, $resolvedArgs, suspectUndeclared: $suspect)); @@ -4243,7 +4267,7 @@ private function captureAliasBoundObligation(string $fqn, array $entry, array $p } } $this->obligations->add( - $this->resolveAliasParams($fqn, $entry), + $this->resolveAliasParams($fqn, $entry, $line), $paddedArgs, "type alias `{$fqn}`", new SourceLocation($this->filepath ?? '', $line), @@ -4283,7 +4307,7 @@ private function padAliasArgs(string $fqn, array $entry, array $expandedArgs, in XphpSourceParser::CODE_ALIAS_ARITY, ); } - $params = $this->resolveAliasParams($fqn, $entry); + $params = $this->resolveAliasParams($fqn, $entry, $line); $paramNames = array_column($entry['params'], 'name'); $padded = $expandedArgs; for ($i = $given; $i < $total; $i++) { @@ -4332,16 +4356,33 @@ private function resolveAliasBody(string $fqn, array $entry): array * carrying the resolved bound and default. Cached per FQN; feeds both default-padding * (`padAliasArgs`) and bound enforcement (`captureAliasBoundObligation`). * + * A parameter's bound may itself name an alias (`type B`), which expands here via + * `buildBoundExpr`. If that bound refers (directly or transitively) back to this alias, the + * `$inFlight` guard turns the otherwise-unbounded recursion into a clean `xphp.alias_cycle` + * — the body-cycle `$visited` guard in `expandAliasToUnion` does not cover the bound axis. + * * @param array{params:list, body:list} $entry * @return list */ - private function resolveAliasParams(string $fqn, array $entry): array + private function resolveAliasParams(string $fqn, array $entry, int $line): array { // @infection-ignore-all ReturnRemoval -- the cache is an optimization; resolution is // deterministic for a fixed context, so re-resolving on a cache miss is equivalent. if (isset($this->aliasParamsCache[$fqn])) { return $this->aliasParamsCache[$fqn]; } + if (isset($this->aliasParamsInFlight[$fqn])) { + throw new XphpParseException( + "Type alias `{$fqn}` is defined (directly or transitively) in terms of itself.", + $line, + XphpSourceParser::CODE_ALIAS_CYCLE, + ); + } + // @infection-ignore-all TrueValue -- a presence set: the isset() guard above reads key + // existence, not the value, so true vs false is unobservable. Never cleared, and it + // needn't be: the cache check above short-circuits a COMPLETED alias before this guard, + // so a lingering flag can only ever mark an alias still mid-resolution (a real cycle). + $this->aliasParamsInFlight[$fqn] = true; $saved = $this->typeParamStack; $this->typeParamStack[] = array_column($entry['params'], 'name'); $resolved = array_map( diff --git a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php index 6e8a785b..91986ba0 100644 --- a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php +++ b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php @@ -428,6 +428,86 @@ public function testAValidBoundedUseIsNotFalselyReportedWhenTheSameFileAbortsPar self::assertNotContains(Registry::CODE_BOUND_VIOLATION, $codes, 'the valid bounded use must not be falsely flagged'); } + public function testAnAliasUsedAsAParameterBoundIsExpanded(): void + { + // `type Named = Face` used as a bound must check against Face, not a phantom `App\Named`. A + // satisfying argument compiles; a violating one is rejected against the REAL type. + $ok = [ + 'C.xphp' => " { public function __construct(public T \$i) {} }\ntype Named = Face;\ntype B = Bag;\nfunction f(): B { return new Bag::(new Widget()); }\n", + ]; + self::assertFalse($this->check($ok)->hasErrors(), 'a class satisfying the aliased bound compiles'); + + $bad = [ + 'C.xphp' => " { public function __construct(public T \$i) {} }\ntype Named = Face;\ntype B = Bag;\nfunction f(): B { return new Bag::(new Plain()); }\n", + ]; + $collector = $this->check($bad); + self::assertRejected($collector, Registry::CODE_BOUND_VIOLATION, 'does not extend/implement "App\\Face"'); + $messages = implode("\n", array_map(static fn ($d): string => $d->message, $collector->all())); + self::assertStringNotContainsString('App\\Named', $messages, 'the bound must name the expanded type, not the alias'); + } + + public function testAnAliasUsedAsAClassParameterBoundIsExpanded(): void + { + // The same expansion fixes the pre-existing class-parameter case (previously an + // xphp.undeclared_type on the phantom alias name): a satisfying arg compiles, a violating one + // is rejected against the real type. + $ok = [ + 'C.xphp' => " { public function __construct(public T \$i) {} }\nfunction f(): Box { return new Box::(new Widget()); }\n", + ]; + self::assertFalse($this->check($ok)->hasErrors(), 'a class satisfying the aliased class bound compiles'); + + $bad = [ + 'C.xphp' => " { public function __construct(public T \$i) {} }\nfunction f(): Box { return new Box::(new Plain()); }\n", + ]; + self::assertRejected($this->check($bad), Registry::CODE_BOUND_VIOLATION, 'does not extend/implement "App\\Face"'); + } + + public function testAMethodGenericAliasBoundIsExpanded(): void + { + // A generic METHOD's parameter bound also routes through the fix (the GenericMethodCompiler + // path) — an aliased bound satisfied by the argument compiles. + $ok = [ + 'C.xphp' => "(T \$x): T { return \$x; }\n public function call(): void { \$this->m::(new Widget()); }\n}\n", + ]; + self::assertFalse($this->check($ok)->hasErrors(), 'a method-generic aliased bound satisfied by the argument compiles'); + } + + public function testAUnionAliasUsedAsABoundIsAnyOf(): void + { + // A union alias `type Either = X|Y` as a bound means "arg is X or Y" (BoundUnion any-of): an + // argument implementing either passes; one implementing neither is rejected against the union. + $ok = [ + 'C.xphp' => " { public function __construct(public T \$i) {} }\ntype Either = X|Y;\ntype B = Bag;\nfunction f(): B { return new Bag::(new AX()); }\n", + ]; + self::assertFalse($this->check($ok)->hasErrors(), 'an argument implementing one member of the union bound compiles'); + + $bad = [ + 'C.xphp' => " { public function __construct(public T \$i) {} }\ntype Either = X|Y;\ntype B = Bag;\nfunction f(): B { return new Bag::(new Neither()); }\n", + ]; + self::assertRejected($this->check($bad), Registry::CODE_BOUND_VIOLATION, 'does not satisfy "App\\X | App\\Y"'); + } + + public function testAnAliasToAliasBoundResolvesTransitively(): void + { + // A bound naming an alias whose body is itself an alias resolves through to the real type. + $ok = [ + 'C.xphp' => " { public function __construct(public T \$i) {} }\ntype Named = Face;\ntype Alias = Named;\ntype B = Bag;\nfunction f(): B { return new Bag::(new Widget()); }\n", + ]; + self::assertFalse($this->check($ok)->hasErrors(), 'an alias-to-alias bound resolves to the real type'); + } + + public function testASelfReferentialGenericAliasBoundIsRejectedAsACycleNotACrash(): void + { + // A generic alias whose own parameter bound refers back to itself would recurse without bound + // through resolveAliasParams; the in-flight guard turns it into a clean xphp.alias_cycle in + // both modes rather than a stack overflow. + $files = [ + 'C.xphp' => " { public function __construct(public T \$i) {} }\ntype A = Bag;\nfunction f(): A { return new Bag::(new User()); }\n", + ]; + self::assertRejected($this->check($files), XphpSourceParser::CODE_ALIAS_CYCLE, 'in terms of itself'); + $this->assertCompileThrows($files, 'in terms of itself'); + } + public function testUnsupportedAliasBodyIsRejectedInBothModes(): void { // An intersection (and DNF / closure) body is recognized (stripped) but rejected with a clear From 5743023bd851e85ae9bbc848204430b565bf6b03 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 31 Jul 2026 08:33:43 +0000 Subject: [PATCH 41/46] docs(type-aliases): note alias-in-bound support and the single-namespace assumption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record that a parameter bound may name an alias, and add a caveat that a generic alias's body / bound / default resolves in the using file's namespace — correct under one namespace per file (PSR), a mis-resolution risk only in multi-namespace files. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- docs/caveats.md | 7 +++++++ docs/syntax/type-aliases.md | 3 ++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4157bb4f..aa1ab491 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 concrete-instantiation, `type UserMap = Pair`); parameters carry **defaults** (`type P` — a use may omit trailing defaulted arguments) and **bounds** (`type B` — an argument that violates the bound is a - compile error), like a generic class. A non-generic alias declared in one file is + compile error; the bound may itself name an alias), like a generic class. A non-generic alias declared in one file is usable in another (**cross-file**, whole-program); a generic alias is file-local. A cyclic (`xphp.alias_cycle`), arity-mismatched (`xphp.alias_arity`), class-colliding (`xphp.alias_class_collision`), duplicate (`xphp.alias_duplicate`), diff --git a/docs/caveats.md b/docs/caveats.md index 30594a6c..972980b0 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -121,6 +121,13 @@ Cross-file, an alias colliding with a **class in another file**, or the same ali declared in **two files**, is not detected (both are within one file — `xphp.alias_class_collision` / `xphp.alias_duplicate`). +A generic alias's **body, parameter bounds, and defaults resolve in the file that +*uses* the alias**, not the one that declares it. Under one `namespace {}` per file +(the PSR norm) these are the same, so it never bites; but in a file with multiple +namespace blocks, a bare (non-qualified) name in an alias's body/bound/default is +resolved against the using namespace and can mis-resolve. Keep one namespace per +file, or fully-qualify such names. + ### Why The body is limited to a single head, a flat union, or a nullable because those diff --git a/docs/syntax/type-aliases.md b/docs/syntax/type-aliases.md index c140a6c9..b436f3cc 100644 --- a/docs/syntax/type-aliases.md +++ b/docs/syntax/type-aliases.md @@ -75,7 +75,8 @@ no separate code path and no runtime cost. defaulted arguments — `P` fills `B = A = int`), and `type B = Bag;` (a use whose argument does not satisfy the bound is a compile error, the same `xphp.bound_violation` a class - instantiation raises). + instantiation raises). A bound may itself name an alias — `type Named = + Face; type B` checks against `Face`. - **Cross-file**: a **non-generic** alias declared in one file is usable in another of the same build (the whole program shares one alias table). A **generic** alias (one with type parameters) is **file-local** — use it From daec1721a1b894051a3d69b3845e12bd2d81d2fe Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 31 Jul 2026 17:28:58 +0000 Subject: [PATCH 42/46] docs(type-aliases): sync comparison grid, error catalog, and feature indexes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type aliases shipped but several cross-cutting docs still described them as unshipped or omitted their diagnostics: - comparison feature grid: xphp "Generic type aliases" ❌ → ⚠️ (shipped, with the body-shape and file-local-generic caveats) - error catalog: add the six alias diagnostic codes (cycle, arity, class_collision, duplicate, unsupported_body, compound_in_non_slot) - docs/index and README: move type aliases from "under exploration" / "remaining" to shipped - syntax index: correct the one-line summary (non-generic cross-file, generic file-local; defaults + bounds) - type-bounds: note a bound may name an alias Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 6 +++--- docs/errors.md | 6 ++++++ docs/guides/comparison.md | 2 +- docs/index.md | 6 +++--- docs/syntax/index.md | 2 +- docs/syntax/type-bounds.md | 4 +++- 6 files changed, 17 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e778b135..4dfc9323 100644 --- a/README.md +++ b/README.md @@ -85,10 +85,10 @@ genuinely [hard work](https://thephp.foundation/blog/2024/08/19/state-of-generic The object model that's served the ecosystem for two decades doesn't bend easily. -Supporting generics proves that the compile-to-vanilla model handles non-trivial -type-system additions. The remaining features are on +Supporting generics — and now type aliases — proves that the compile-to-vanilla +model handles non-trivial type-system additions. Further features are on the [roadmap](docs/roadmap.md): -type aliases, literal types, mapped and conditional types to name a few. +literal types, mapped and conditional types to name a few. ## Quick start diff --git a/docs/errors.md b/docs/errors.md index 8e333072..4e2d3141 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -56,6 +56,12 @@ The `json` and `github` formats tag each diagnostic with a stable code: | `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.closure_conformance` | a closure literal returned against a `Closure(...)` type doesn't conform to it — its parameters aren't wide enough, its return isn't narrow enough, its by-reference-ness differs, or its arity is incompatible | | `xphp.parse_error` | the source can't be parsed — either a PHP syntax error after the generic strip pass, or a parse-time xphp rejection (a variance marker on a method/closure, a malformed generic default, a generic clause on a `use` import, a `Closure(...)` signature with a defaulted or untyped parameter, or a `Closure(...)` signature type in an unsupported position such as a generic argument or bound), reported at the offending line | +| `xphp.alias_cycle` | a [type alias](syntax/type-aliases.md) defined, directly or transitively, in terms of itself — through its body (`type A = B; type B = A;`) or a parameter bound (`type A`) | +| `xphp.alias_arity` | a type-alias use whose type-argument count is outside the alias's accepted range — fewer than the required (default-less) parameters or more than it declares (`type P = …;` used as `P`; a default widens the range) | +| `xphp.alias_class_collision` | a type-alias name collides with a class, interface, or trait of the same name in the same file (no silent shadowing) | +| `xphp.alias_duplicate` | the same type-alias name is declared more than once in a file | +| `xphp.alias_unsupported_body` | a type-alias body that is not a single head, a flat union, or a nullable — an intersection (`A & B`), a DNF (`(A & B) \| C`), or a closure signature (`Closure(int): int`) | +| `xphp.alias_compound_in_non_slot` | a union / nullable type alias used somewhere other than the whole type of a parameter, property, return, or class-constant slot (e.g. as a generic argument or nested in another compound type) | | `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/guides/comparison.md b/docs/guides/comparison.md index 0e6404ff..9d947511 100644 --- a/docs/guides/comparison.md +++ b/docs/guides/comparison.md @@ -39,7 +39,7 @@ than erasure can. | Reified T at runtime | ✅ (via AOT) | ❌ (erased) | ❌ | ⚠️ (`inline fun` only — can't reify a class type parameter) | ✅ (monomorphic) | | `instanceof OriginalFqn` works | ✅ | ✅ (trivially: only one class exists at runtime) | n/a | n/a | n/a | | Real subtype edges between specializations | ⚠️ (common case works; some covariant upcasts are unschedulable or may not converge) | ❌ (erased) | n/a | n/a | n/a | -| Generic type aliases | ❌ | ❌ | ✅ | ✅ | ✅ | +| Generic type aliases | ⚠️ (compile-time substitution; single-head / union / nullable bodies, parameter defaults + bounds, aliases usable as bounds; a generic alias is file-local, and intersection / DNF / closure-signature bodies aren't supported) | ❌ | ✅ | ✅ | ✅ | | Wildcard / `*` (use-site existential) | ⚠️ partial (via marker) | n/a (erased) | ⚠️ via `any` (bivariant escape hatch — loses type discipline) | ✅ (`Box<*>`) | n/a | | Use-site variance | ❌ | ❌ | ❌ | ✅ | n/a | | Variadic generics | ❌ | ❌ | ✅ | ❌ | ⚠️ tuples | diff --git a/docs/index.md b/docs/index.md index 1897cf3b..0ff8390d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -57,6 +57,6 @@ and the gap is explicit in [comparison](guides/comparison.md) and Generics are the first substantial chunk of work in xphp, but the roadmap is much broader. See [roadmap](roadmap.md) for what's -shipped and for the discovery items under exploration (type aliases, -mapped types, variadic generics, generic enums, source maps, AST -macros, and more). +shipped — generics and, now, [type aliases](syntax/type-aliases.md) — +and for the discovery items under exploration (mapped types, variadic +generics, generic enums, source maps, AST macros, and more). diff --git a/docs/syntax/index.md b/docs/syntax/index.md index a95af6f0..ba59f2e8 100644 --- a/docs/syntax/index.md +++ b/docs/syntax/index.md @@ -22,7 +22,7 @@ first. | [Pseudo-types](pseudo-types.md) | `self` / `static` / `parent` and the `new self::(...)` form | | [Turbofish](turbofish.md) | All four call-site shapes plus variable and empty turbofish | | [Array sugar](array-sugar.md) | `T[]` shorthand | -| [Type aliases](type-aliases.md) | `type Pair = …;`, compile-time substitution, file-local | +| [Type aliases](type-aliases.md) | `type Pair = …;`, compile-time substitution; union/nullable bodies, parameter defaults + bounds; non-generic aliases cross-file, generic aliases file-local | | [Exceptions](exceptions.md) | Generic exceptions, `catch (HttpError $e)`, bare and union catch | ## Quick reference card diff --git a/docs/syntax/type-bounds.md b/docs/syntax/type-bounds.md index b4ff77aa..36502860 100644 --- a/docs/syntax/type-bounds.md +++ b/docs/syntax/type-bounds.md @@ -70,7 +70,9 @@ once it sees `public int $value`. ## Rules -- A bound can be any valid PHP class or interface name. +- A bound can be any valid PHP class or interface name, or a + [type alias](type-aliases.md) that resolves to one (`type Named = Face; + T : Named` checks against `Face`; a union alias becomes a union bound). - Intersection: `T : A & B` — concrete must satisfy both. - Union: `T : A | B` — any operand suffices. - DNF: `T : (A & B) | C` — outer OR of inner ANDs. From b6dfa6bb26123e1a89ea5649c20e498f71553503 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 31 Jul 2026 19:18:08 +0000 Subject: [PATCH 43/46] feat(monomorphize): make type aliases file-local (drop cross-file pre-pass) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A type alias is a file-local declaration by design, like PHP's own `use` alias — there is no whole-program alias table. Remove WI-03's cross-file machinery: the Compiler's collectGlobalAliases, the parser's aliasTableOf, and the externalAliases parameter threaded through parse / parseWithMap / resolveAndAttach. Expansion now consults only the file's own alias table. This makes generic and non-generic aliases behave consistently (both file-local), and dissolves the two cross-file gaps entirely: a generic alias used in another file surfaces as an undefined template, a non-generic one is simply left unexpanded (flagged by the later PHP/PHPStan pass), and same-file duplicate/collision detection is unchanged. Share a vocabulary by declaring the alias in each file that uses it. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Transpiler/Monomorphize/Compiler.php | 46 ++------------- .../Monomorphize/XphpSourceParser.php | 50 ++++------------- .../Monomorphize/TypeAliasIntegrationTest.php | 56 +++++++++---------- .../compile/type_aliases/source/Consumer.xphp | 13 +++-- .../compile/type_aliases/verify/runtime.php | 4 +- 5 files changed, 51 insertions(+), 118 deletions(-) diff --git a/src/Transpiler/Monomorphize/Compiler.php b/src/Transpiler/Monomorphize/Compiler.php index 949a59a5..7b040f0a 100644 --- a/src/Transpiler/Monomorphize/Compiler.php +++ b/src/Transpiler/Monomorphize/Compiler.php @@ -31,8 +31,6 @@ * 4. Emit rewritten user code — rewrite each original source AST (strip generic class defs, * rewrite generic Name references), pretty-print, and write to the target directory. * 5. Persist registry — write .xphp-cache/registry.json. - * - * @phpstan-import-type BoundDict from XphpSourceParser */ final readonly class Compiler { @@ -463,13 +461,11 @@ public function check(FilepathArray $sources): DiagnosticCollector $diagnostics = new DiagnosticCollector(); $aliasBoundObligations = new AliasBoundObligationCollector(); // Read every source up front — OUTSIDE the try so an I/O failure surfaces as itself, not a - // mislabeled "parse error" — then merge a whole-program alias table so a cross-file alias use - // resolves. Only parsing is treated as a per-file, recoverable diagnostic. + // mislabeled "parse error". Only parsing is treated as a per-file, recoverable diagnostic. $contents = []; foreach ($sources->filepaths as $filepath) { $contents[$filepath] = $this->fileReader->read($filepath); } - $globalAliases = $this->collectGlobalAliases($contents); $astPerFile = []; foreach ($contents as $filepath => $content) { // Buffer this file's alias-bound obligations and commit them to the shared collector only @@ -478,7 +474,7 @@ public function check(FilepathArray $sources): DiagnosticCollector // now-absent types and mis-report a valid use as a bound violation). $fileObligations = new AliasBoundObligationCollector(); try { - $astPerFile[$filepath] = $this->sourceParser->parse($content, $globalAliases, $filepath, $fileObligations); + $astPerFile[$filepath] = $this->sourceParser->parse($content, $filepath, $fileObligations); $aliasBoundObligations->absorb($fileObligations); } catch (PhpParserError $e) { $line = $e->getStartLine(); @@ -598,48 +594,14 @@ public function check(FilepathArray $sources): DiagnosticCollector */ private function parseAll(FilepathArray $sources, ?AliasBoundObligationCollector $obligations = null): array { - $contents = []; - foreach ($sources->filepaths as $filepath) { - $contents[$filepath] = $this->fileReader->read($filepath); - } - $globalAliases = $this->collectGlobalAliases($contents); - $astPerFile = []; - foreach ($contents as $filepath => $content) { - $astPerFile[$filepath] = $this->sourceParser->parse($content, $globalAliases, $filepath, $obligations); + foreach ($sources->filepaths as $filepath) { + $astPerFile[$filepath] = $this->sourceParser->parse($this->fileReader->read($filepath), $filepath, $obligations); } return $astPerFile; } - /** - * Merge every source's file-local type-alias table into one whole-program table, so an alias - * declared in one file can be used in another. A file whose own aliases are malformed (same-file - * duplicate / collision / unsupported body) raises here and is skipped — the same error - * re-surfaces (and, in check mode, is collected) when that file is parsed for real. - * - * @param array $contents filepath => source - * @return array, body:list<\XPHP\Transpiler\Monomorphize\TypeRef>}> - */ - private function collectGlobalAliases(array $contents): array - { - $global = []; - foreach ($contents as $content) { - try { - foreach ($this->sourceParser->aliasTableOf($content) as $fqn => $entry) { - $global[$fqn] = $entry; - } - } catch (RuntimeException) { - // Any parse-time rejection — skip this file's aliases; the same error re-surfaces (and - // is collected in check mode) when the file is parsed for real. Both a nikic syntax - // error (PhpParser\Error) and an xphp scanner/alias error (XphpParseException) extend - // RuntimeException, so this catches every parse-time failure. - } - } - - return $global; - } - private static function relativePath(string $base, string $filepath): string { $base = rtrim($base, '/') . '/'; diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 7b568b4b..66a5650b 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -145,43 +145,18 @@ public function __construct(private readonly Parser $parser) } /** - * @param array, body:list}>|null $externalAliases - * a whole-program alias table (from {@see aliasTableOf} across every source) used for - * cross-file expansion; null keeps aliases file-local (standalone parse / LSP). + * A type alias is file-local: only the aliases declared in `$source` are visible to it, mirroring + * PHP's `use`-alias scoping. There is no whole-program alias table. + * * @param ?string $filepath the source file, threaded only so a captured alias-bound obligation * can carry an accurate SourceLocation; null on the standalone parse path. * @param ?AliasBoundObligationCollector $obligations sink for alias parameter-bound obligations, * verified after the hierarchy is built; null (inert) on the standalone parse path. * @return list */ - public function parse(string $source, ?array $externalAliases = null, ?string $filepath = null, ?AliasBoundObligationCollector $obligations = null): array - { - return $this->parseWithMap($source, $externalAliases, $filepath, $obligations)[0]; - } - - /** - * The file-local type-alias table for a single source — its `type` declarations - * keyed by FQN, bodies unresolved — WITHOUT expanding any uses. The Compiler merges these across - * every source into a whole-program table so an alias declared in one file is usable in another. - * Same-file duplicate / class-collision / unsupported-body rejections still fire (per file) via - * the main parse; the caller catches and skips a file that raises one here. - * - * @return array, body:list}> - */ - public function aliasTableOf(string $source): array + public function parse(string $source, ?string $filepath = null, ?AliasBoundObligationCollector $obligations = null): array { - [, , , $cleaned, $byteOffsetMap, , $aliasMarkers] = $this->scanAndStrip($source); - // @infection-ignore-all ReturnRemoval -- optimization: an alias-free file (the common case) - // skips the re-parse; without it buildAliasTable([]) returns [] anyway. - if ($aliasMarkers === []) { - return []; - } - $ast = $this->parser->parse($cleaned); - if ($ast === null) { - return []; - } - /** @var list $ast — nikic's parse() returns array; keys are always 0..N-1. */ - return self::buildAliasTable($ast, $aliasMarkers, $byteOffsetMap); + return $this->parseWithMap($source, $filepath, $obligations)[0]; } /** @@ -193,11 +168,10 @@ public function aliasTableOf(string $source): array * Returns the identity map when no length-changing replacements fired * (the common case for files without `T[]` array-suffix sugar). * - * @param array, body:list}>|null $externalAliases * @param ?AliasBoundObligationCollector $obligations sink for alias parameter-bound obligations (null = inert) * @return array{0: list, 1: ByteOffsetMap} */ - public function parseWithMap(string $source, ?array $externalAliases = null, ?string $filepath = null, ?AliasBoundObligationCollector $obligations = null): array + public function parseWithMap(string $source, ?string $filepath = null, ?AliasBoundObligationCollector $obligations = null): array { [$classMarkers, $nameMarkers, $methodMarkers, $cleanedSource, $byteOffsetMap, $closureMarkers, $aliasMarkers] = $this->scanAndStrip($source); @@ -217,7 +191,7 @@ public function parseWithMap(string $source, ?array $externalAliases = null, ?st } /** @var list $ast — nikic's parse() returns array; runtime keys are always 0..N-1. */ - $unbound = $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap, $aliasMarkers, $externalAliases, $filepath, $obligations); + $unbound = $this->resolveAndAttach($ast, $classMarkers, $nameMarkers, $methodMarkers, $closureMarkers, $byteOffsetMap, $aliasMarkers, $filepath, $obligations); // @infection-ignore-all — defensive backstop, unreachable from valid input by // construction (see unboundDeclarationMarkerMessage): no test can reach a // mutant here. The message builder is pinned by direct unit tests; this @@ -3046,16 +3020,14 @@ private static function collectClassLikeFqns(array $ast): array * @param list}> $methodMarkers * @param list $closureMarkers * @param list, body:?list, bytePosition:int, line:int}> $aliasMarkers - * @param array, body:list}>|null $externalAliases * @param ?AliasBoundObligationCollector $obligations sink for alias parameter-bound obligations (null = inert) */ - private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers, array $closureMarkers, ByteOffsetMap $byteOffsetMap, array $aliasMarkers, ?array $externalAliases = null, ?string $filepath = null, ?AliasBoundObligationCollector $obligations = null): ?string + private function resolveAndAttach(array $ast, array $classMarkers, array $nameMarkers, array $methodMarkers, array $closureMarkers, ByteOffsetMap $byteOffsetMap, array $aliasMarkers, ?string $filepath = null, ?AliasBoundObligationCollector $obligations = null): ?string { // buildAliasTable runs the per-file rejections (same-file duplicate / class-collision / - // unsupported body) regardless; a whole-program table, when injected, is what expansion - // actually looks aliases up in so a use can reach an alias declared in another file. - $fileTable = self::buildAliasTable($ast, $aliasMarkers, $byteOffsetMap); - $aliasTable = $externalAliases ?? $fileTable; + // unsupported body). A type alias is file-local, so this file's own table is the only one + // expansion consults — an alias declared in another file is simply not visible here. + $aliasTable = self::buildAliasTable($ast, $aliasMarkers, $byteOffsetMap); $traverser = new NodeTraverser(); $visitor = new /** diff --git a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php index 91986ba0..79a0a249 100644 --- a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php +++ b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php @@ -205,40 +205,38 @@ public function testNullableFollowedByUnionIsDeclinedAsUnsupported(): void $this->assertCompileThrows($files, 'unsupported body'); } - public function testAliasesAreVisibleAcrossFilesInTheSameBuild(): void - { - // Whole-program alias table: an alias declared in one file is usable in another (union and - // plain-class bodies both). - $dist = $this->compile([ - 'Types.xphp' => " "compile([ + 'Types.xphp' => " " " " " { public function __construct(public K \$k, public V \$v) {} }\ntype Pair = Dict;\n", + 'Consumer.xphp' => " { return new Dict::(1, 'x'); }\n", ]; - self::assertRejected($this->check($files), XphpSourceParser::CODE_ALIAS_DUPLICATE, 'declared more than once'); + self::assertRejected($this->check($generic), 'xphp.undefined_template', 'App\\Pair'); } - public function testAliasFileWithASyntaxErrorIsCollectedNotCrashed(): void + public function testRedeclaringAnAliasPerFileSharesItAsFileLocal(): void { - // The pre-pass re-parses an alias-bearing file to collect its aliases; a nikic SYNTAX error - // there (a PhpParserError, not an xphp RuntimeException) must be caught/skipped too, so check - // collects it for real rather than crashing the whole-program alias collection. - $files = [ - 'Broken.xphp' => "check($files)->hasErrors(), 'a syntax error in an alias file is collected, not crashed'); + // The share-a-vocabulary pattern under file-local scoping: declare the alias in each file that + // uses it (a zero-cost substitution). The target class is a normal cross-file class reference. + $dist = $this->compile([ + 'Types.xphp' => " "widen('cross'); +$localAlias = new LocalAlias(); +$localValue = $localAlias->widen('local'); diff --git a/test/fixture/compile/type_aliases/verify/runtime.php b/test/fixture/compile/type_aliases/verify/runtime.php index 694918b0..1918210b 100644 --- a/test/fixture/compile/type_aliases/verify/runtime.php +++ b/test/fixture/compile/type_aliases/verify/runtime.php @@ -41,6 +41,6 @@ Assert::assertSame('hi', $numValue, 'union alias Num expanded to int|string in the param/return slots'); Assert::assertNull($maybeValue, 'nullable alias MaybeUser expanded to ?User'); - // Cross-file: Consumer.xphp used `Num` declared in Types.xphp. - Assert::assertSame('cross', $crossValue, 'an alias declared in Types.xphp was usable in Consumer.xphp'); + // File-local: Consumer.xphp declares its OWN `Num` and it expands independently of Types.xphp. + Assert::assertSame('local', $localValue, 'a file-local alias in a second file expands there'); }; From fef5546a206ce85543d525507d1416040a429991 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 31 Jul 2026 19:22:08 +0000 Subject: [PATCH 44/46] docs(type-aliases): reframe file-locality as by-design, not a limitation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type aliases are now file-local for all cases (WI-07). Rewrite the docs to present file-locality as an intentional design choice — an alias is a local naming convenience like a `use` alias, not a whole-program symbol — rather than a "safe subset first" limitation: caveats, syntax tour + index, roadmap (timeline + shipped), the comparison grid caveat, CHANGELOG, and ADR-0023's delivered-scope note. Drop the cross-file duplicate/collision "not detected" notes (moot — per-file scoping has nothing to detect across files). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 +- .../adr/0023-type-alias-declaration-syntax.md | 3 +- docs/caveats.md | 51 ++++++++++--------- docs/guides/comparison.md | 2 +- docs/roadmap.md | 8 +-- docs/syntax/index.md | 2 +- docs/syntax/type-aliases.md | 17 ++++--- 7 files changed, 47 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa1ab491..5f09a033 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 concrete-instantiation, `type UserMap = Pair`); parameters carry **defaults** (`type P` — a use may omit trailing defaulted arguments) and **bounds** (`type B` — an argument that violates the bound is a - compile error; the bound may itself name an alias), like a generic class. A non-generic alias declared in one file is - usable in another (**cross-file**, whole-program); a generic alias is file-local. + compile error; the bound may itself name an alias), like a generic class. An alias + is **file-local** — visible only in the file that declares it, like a `use` alias. A cyclic (`xphp.alias_cycle`), arity-mismatched (`xphp.alias_arity`), class-colliding (`xphp.alias_class_collision`), duplicate (`xphp.alias_duplicate`), unsupported-body (`xphp.alias_unsupported_body` — intersection / DNF / closure), diff --git a/docs/adr/0023-type-alias-declaration-syntax.md b/docs/adr/0023-type-alias-declaration-syntax.md index dc146c38..66479adc 100644 --- a/docs/adr/0023-type-alias-declaration-syntax.md +++ b/docs/adr/0023-type-alias-declaration-syntax.md @@ -61,7 +61,8 @@ and needs no runtime identity. - Trade-off: for the *generic* case xphp defines surface ahead of PHP (which deferred it), a bet on the declaration-form consensus. The non-generic import form (`use type … as`) could be added later as a parity synonym without disturbing this decision. -- Trade-off: the delivered scope is single-head / union / nullable bodies, cross-file; +- Trade-off: the delivered scope is single-head / union / nullable bodies, **file-local** (an + alias is scoped to its file like a `use` alias, by design — see option D below); intersection / DNF / closure bodies and compound-in-non-slot positions are still rejected (see the [caveat](../caveats.md#type-alias-body-and-position-limits)) — a safe subset, with the richer bodies as later work. diff --git a/docs/caveats.md b/docs/caveats.md index 972980b0..b44268a6 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -91,10 +91,11 @@ behavior, only makes the type explicit. ## Type-alias body and position limits -[Type aliases](syntax/type-aliases.md) are a compile-time substitution. A single -head (`Ident`, `Box`), a union (`int|string`), and a nullable (`?Box`) body -are all supported; parameters may carry defaults and bounds; and a **non-generic** -alias declared in one file is usable in another. Three limits remain. +[Type aliases](syntax/type-aliases.md) are a compile-time substitution, and are +**file-local by design** — an alias is visible only in the file that declares it, +like a PHP `use` alias. A single head (`Ident`, `Box`), a union (`int|string`), +and a nullable (`?Box`) body are all supported; parameters may carry defaults and +bounds. Two limits remain, both on the body shape and its position. ### ❌ What doesn't work @@ -109,24 +110,31 @@ function f(Num $n): void {} // ✓ whole param slot function g(Bag $x): void {} // ✗ xphp.alias_compound_in_non_slot — generic argument function h(Num&Extra $x): void {} // ✗ nested in another intersection/union $b = new Num(); // ✗ compound alias in `new` / extends / a bound +``` + +### 🔒 File-local (by design) -// A GENERIC alias (one with type parameters) is file-local: +An alias is scoped to its file, like a `use` alias — not visible in another file: + +```php // File Types.xphp +type UserId = Ident; type Pair = Dict; // File Other.xphp — a DIFFERENT file -function f(): Pair { /* … */ } // ✗ Pair is not visible here (generic alias is file-local) +function f(): UserId { … } // UserId is a plain unknown type here — not expanded +function g(): Pair { … } // ✗ Pair is not visible — an undefined template ``` -Cross-file, an alias colliding with a **class in another file**, or the same alias -declared in **two files**, is not detected (both are within one file — -`xphp.alias_class_collision` / `xphp.alias_duplicate`). +To share a vocabulary, **declare the alias in each file that uses it** (a zero-cost +substitution) or reference the underlying type directly. Because scoping is +per-file there is no cross-file duplicate or collision to detect — two files each +with `type Id = …` are simply independent local aliases. (Same-file duplicate / +class-collision *are* caught — `xphp.alias_duplicate` / `xphp.alias_class_collision`.) -A generic alias's **body, parameter bounds, and defaults resolve in the file that -*uses* the alias**, not the one that declares it. Under one `namespace {}` per file -(the PSR norm) these are the same, so it never bites; but in a file with multiple -namespace blocks, a bare (non-qualified) name in an alias's body/bound/default is -resolved against the using namespace and can mis-resolve. Keep one namespace per -file, or fully-qualify such names. +An alias's body, bounds, and defaults resolve in the namespace that **uses** it. +Under one `namespace {}` per file (the PSR norm) that is always the declaring +namespace; in a file with multiple namespace blocks a bare name can mis-resolve — +keep one namespace per file, or fully-qualify. ### Why @@ -135,12 +143,9 @@ lower cleanly into a PHP type node. An intersection or DNF pulls in *distributio (`(A|B)&C → (A&C)|(B&C)`), and a union/nullable has no single identity to hash or anchor, so it is representable only as the whole type of a param / property / return / class-constant slot — anywhere else it is rejected loudly rather than -mis-compiled. Cross-file expansion is a whole-program pre-pass that merges each -file's alias table; a *generic* alias use is expanded before that table is -consulted for arguments, so a generic alias resolves only within its own file -(and its declared parameter bounds are enforced there). Global duplicate/collision -checking across the merge is a later refinement. These are "make the safe subset -solid first" trades, not permanent design limits. +mis-compiled. These are "make the safe subset solid first" trades, candidates to +lift later. File-locality, by contrast, is a deliberate choice — an alias is a +local naming convenience, like `use`, not a whole-program symbol — not a limit. ### ✅ Workaround @@ -148,8 +153,8 @@ solid first" trades, not permanent design limits. a named class or interface and alias *that*. - Use a union/nullable alias as the whole type of a slot; write the union directly where you need it as a generic argument or nested in another compound type. -- Declare a generic alias in each file that uses it (a zero-cost substitution), or - reference the underlying generic type directly across files. +- Declare an alias in each file that uses it (a zero-cost substitution), or + reference the underlying type directly across files. --- diff --git a/docs/guides/comparison.md b/docs/guides/comparison.md index 9d947511..de325cbe 100644 --- a/docs/guides/comparison.md +++ b/docs/guides/comparison.md @@ -39,7 +39,7 @@ than erasure can. | Reified T at runtime | ✅ (via AOT) | ❌ (erased) | ❌ | ⚠️ (`inline fun` only — can't reify a class type parameter) | ✅ (monomorphic) | | `instanceof OriginalFqn` works | ✅ | ✅ (trivially: only one class exists at runtime) | n/a | n/a | n/a | | Real subtype edges between specializations | ⚠️ (common case works; some covariant upcasts are unschedulable or may not converge) | ❌ (erased) | n/a | n/a | n/a | -| Generic type aliases | ⚠️ (compile-time substitution; single-head / union / nullable bodies, parameter defaults + bounds, aliases usable as bounds; a generic alias is file-local, and intersection / DNF / closure-signature bodies aren't supported) | ❌ | ✅ | ✅ | ✅ | +| Generic type aliases | ⚠️ (compile-time substitution; single-head / union / nullable bodies, parameter defaults + bounds, aliases usable as bounds; aliases are file-local, and intersection / DNF / closure-signature bodies aren't supported) | ❌ | ✅ | ✅ | ✅ | | Wildcard / `*` (use-site existential) | ⚠️ partial (via marker) | n/a (erased) | ⚠️ via `any` (bivariant escape hatch — loses type discipline) | ✅ (`Box<*>`) | n/a | | Use-site variance | ❌ | ❌ | ❌ | ✅ | n/a | | Variadic generics | ❌ | ❌ | ✅ | ❌ | ⚠️ tuples | diff --git a/docs/roadmap.md b/docs/roadmap.md index 85075c72..ba4e2ecf 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -51,9 +51,9 @@ timeline : runtime instanceof T : marker interface per template Type aliases - : compile-time substitution + : compile-time substitution, file-local : single-head union and nullable bodies - : whole-program cross-file use + : parameter defaults and bounds Developer experience : RFC-aligned call-site syntax : empty turbofish for all-defaults templates @@ -234,8 +234,8 @@ upcoming one. - Parameters carry **defaults** (`type P` — a use may omit trailing defaulted arguments) and **bounds** (`type B` — an argument that violates the bound is a compile error), like a generic class. -- **Cross-file**: a non-generic alias declared in one file is usable in - another (whole-program alias table); a generic alias is file-local. +- **File-local by design**: an alias is visible only in the file that + declares it (like a `use` alias); declare it per file to share it. - Cyclic, arity-mismatched, class-colliding, duplicate, unsupported-body (intersection / DNF / closure), compound-in-non-slot, and bound-violating uses are loud compile errors in both `compile` and diff --git a/docs/syntax/index.md b/docs/syntax/index.md index ba59f2e8..ed8c1b06 100644 --- a/docs/syntax/index.md +++ b/docs/syntax/index.md @@ -22,7 +22,7 @@ first. | [Pseudo-types](pseudo-types.md) | `self` / `static` / `parent` and the `new self::(...)` form | | [Turbofish](turbofish.md) | All four call-site shapes plus variable and empty turbofish | | [Array sugar](array-sugar.md) | `T[]` shorthand | -| [Type aliases](type-aliases.md) | `type Pair = …;`, compile-time substitution; union/nullable bodies, parameter defaults + bounds; non-generic aliases cross-file, generic aliases file-local | +| [Type aliases](type-aliases.md) | `type Pair = …;`, compile-time substitution, file-local; union/nullable bodies, parameter defaults + bounds | | [Exceptions](exceptions.md) | Generic exceptions, `catch (HttpError $e)`, bare and union catch | ## Quick reference card diff --git a/docs/syntax/type-aliases.md b/docs/syntax/type-aliases.md index b436f3cc..5ba346ed 100644 --- a/docs/syntax/type-aliases.md +++ b/docs/syntax/type-aliases.md @@ -77,10 +77,10 @@ no separate code path and no runtime cost. bound is a compile error, the same `xphp.bound_violation` a class instantiation raises). A bound may itself name an alias — `type Named = Face; type B` checks against `Face`. -- **Cross-file**: a **non-generic** alias declared in one file is usable in - another of the same build (the whole program shares one alias table). A - **generic** alias (one with type parameters) is **file-local** — use it - in the file that declares it (see caveats). +- **File-local**: an alias is visible only in the file that declares it, + like a PHP `use` alias. To share a vocabulary, declare the alias in each + file that uses it (a zero-cost substitution), or reference the underlying + type directly (see caveats). - Aliases compose: an alias body may reference another alias (`type UserMap = Pair`), and an alias may take type parameters used inside its body (`type Pair = Dict>`). @@ -105,20 +105,21 @@ no separate code path and no runtime cost. ## Caveats -Union and nullable bodies and cross-file use all work; the remaining +An alias is **file-local by design** (like a `use` alias). The remaining limits are the body shape and the positions a compound alias can take. See [caveats → type-alias body and position limits](../caveats.md#type-alias-body-and-position-limits) for the details and the reasons: +- **File-local.** An alias is visible only in its own file — declare it in + each file that uses it, or reference the underlying type directly. (Because + scoping is per-file there is no cross-file collision/duplicate to detect; + same-file ones *are* caught.) - **Intersection / DNF / closure bodies** (`A&B`, `(A&B)|C`, `Closure(int): int`) are rejected with `xphp.alias_unsupported_body` — write the type directly or wrap it in a named class/interface. - **A union / nullable alias is a whole-slot type only.** As a generic argument, in `new` / `extends` / a bound, or nested inside another union/intersection, it is `xphp.alias_compound_in_non_slot`. -- **Cross-file collision / duplicate not detected.** An alias colliding - with a class, or the same alias declared, in a *different* file is not - flagged (both are within one file). ## See also From 1c4694f94a66040e19d4c71780f54990fc0272e3 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 2 Aug 2026 18:10:57 +0000 Subject: [PATCH 45/46] fix(monomorphize): correct alias-body message + guard argument-path cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on the type-alias feature: - The `xphp.alias_unsupported_body` message listed unions and nullables as unsupported, but both are supported — it now names only intersection, DNF, and closure-signature bodies. - A self-referential alias whose cycle runs through a generic argument of a non-alias class (`type A = Bag>`) bypassed the cycle guard and recursed without bound; argument expansion now carries the same visited chain as the body, so it is a clean `xphp.alias_cycle`. - Refresh two stale docblocks that said duplicate/cycle/arity diagnostics "land in a later change" — all are implemented. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Monomorphize/XphpSourceParser.php | 22 +++++++++++-------- .../Monomorphize/TypeAliasIntegrationTest.php | 16 ++++++++++++-- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/Transpiler/Monomorphize/XphpSourceParser.php b/src/Transpiler/Monomorphize/XphpSourceParser.php index 66a5650b..2111818f 100644 --- a/src/Transpiler/Monomorphize/XphpSourceParser.php +++ b/src/Transpiler/Monomorphize/XphpSourceParser.php @@ -2897,8 +2897,9 @@ private static function applyReplacements(string $source, array $replacements): * namespace is found by locating the `Namespace_` node whose (original-source) byte span contains * the `type` keyword, so a real class sharing an alias's short name in another namespace never * collides. Bodies stay raw (unresolved) — they resolve lazily at expansion, when the use-site - * namespace context is available. A duplicate FQN keeps the last declaration (a dedicated - * duplicate-alias diagnostic lands in a later change). + * namespace context is available. A duplicate FQN is rejected with `xphp.alias_duplicate` (never + * silently overwritten), and a name colliding with a class/interface/trait with + * `xphp.alias_class_collision`. * * @param list $ast * @param list, body:?list, bytePosition:int, line:int}> $aliasMarkers @@ -2943,8 +2944,8 @@ private static function buildAliasTable(array $ast, array $aliasMarkers, ByteOff if ($marker['body'] === null) { throw new XphpParseException( "Type alias `{$fqn}` has an unsupported body: an alias body must be a single class " - . 'or generic type (unions, intersections, nullables, and closure signatures are ' - . 'not supported). Use a bare type or a named class.', + . 'or generic type, a union, or a nullable (intersection, DNF, and closure-signature ' + . 'bodies are not supported). Use a bare type or a named class.', $marker['line'], self::CODE_ALIAS_UNSUPPORTED_BODY, ); @@ -4140,10 +4141,10 @@ private static function unionMembersToNode(array $members, array $attrs): Node * Recursively expand a type reference against the file-local alias table. A non-alias * head is returned with its arguments expanded; an alias head is substituted with its * body (params → arguments) and re-expanded, so nested and concrete-instantiation aliases - * (`type UserMap = Pair`) resolve fully. A head that recurs into itself is a - * cycle, and a use whose argument count differs from the alias's parameter count is an - * arity error — both fail loudly (refined into `xphp.alias_cycle` / `xphp.alias_arity` - * diagnostics in a later change). + * (`type UserMap = Pair`) resolve fully. A head that recurs into itself + * (through its body or a generic argument) is a cycle, and a use whose argument count + * differs from the alias's parameter count is an arity error — both fail loudly with + * `xphp.alias_cycle` / `xphp.alias_arity`. * * @param list $visited alias FQNs already entered on this expansion chain */ @@ -4177,7 +4178,10 @@ private function expandAlias(TypeRef $ref, array $visited, int $line): TypeRef */ private function expandAliasToUnion(TypeRef $ref, array $visited, int $line): array { - $expandedArgs = array_map(fn (TypeRef $a): TypeRef => $this->expandAlias($a, [], $line), $ref->args); + // Expand each argument on the SAME visited chain — an argument that refers back to an + // alias already being expanded (`type A = Bag>`) is a cycle through the + // argument path; passing an empty chain here would miss it and recurse without bound. + $expandedArgs = array_map(fn (TypeRef $a): TypeRef => $this->expandAlias($a, $visited, $line), $ref->args); $entry = $this->aliasTable[$ref->name] ?? null; if ($entry === null) { return [new TypeRef($ref->name, $expandedArgs, $ref->isScalar, $ref->isTypeParam, $ref->suspectUndeclared)]; diff --git a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php index 79a0a249..5f8ed309 100644 --- a/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php +++ b/test/Transpiler/Monomorphize/TypeAliasIntegrationTest.php @@ -250,6 +250,18 @@ public function testCyclicAliasIsRejectedInBothModes(): void $this->assertCompileThrows($files, 'in terms of itself'); } + public function testCycleThroughAGenericArgumentIsRejectedNotACrash(): void + { + // The cycle passes through the generic ARGUMENT of a non-alias class (`Bag>`), not the + // head. Argument expansion must carry the same visited chain as the body, so this is a clean + // xphp.alias_cycle in both modes rather than unbounded recursion / a stack overflow. + $files = [ + 'C.xphp' => " { public function __construct(public T \$i) {} }\ntype A = Bag>;\nfunction f(): A { throw new \\Exception(); }\n", + ]; + self::assertRejected($this->check($files), XphpSourceParser::CODE_ALIAS_CYCLE, 'in terms of itself'); + $this->assertCompileThrows($files, 'in terms of itself'); + } + public function testAliasArityMismatchIsRejectedInBothModes(): void { $files = [ @@ -514,8 +526,8 @@ public function testUnsupportedAliasBodyIsRejectedInBothModes(): void $files = [ 'C.xphp' => "check($files), XphpSourceParser::CODE_ALIAS_UNSUPPORTED_BODY, $message); $this->assertCompileThrows($files, $message); } From 88de480bd3d87026da502a1d61ab293c4d46f01e Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 6 Aug 2026 15:49:50 +0000 Subject: [PATCH 46/46] docs(changelog): cut the 0.4.0 release Rename the Unreleased section to [0.4.0] - 2026-08-06 with its compare link, and record type-argument inference and enclosing-parameter turbofish grounding under the roadmap's Shipped section. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 3 ++- docs/roadmap.md | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f09a033..b596807e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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.4.0] ### Added @@ -662,6 +662,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). +[0.4.0]: https://github.com/xphp-lang/xphp/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/xphp-lang/xphp/compare/v0.2.1...v0.3.0 [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 diff --git a/docs/roadmap.md b/docs/roadmap.md index ba4e2ecf..55ea8eef 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -57,6 +57,7 @@ timeline Developer experience : RFC-aligned call-site syntax : empty turbofish for all-defaults templates + : optional turbofish via type-argument inference Validation and diagnostics : xphp check validate-only gate : collect-all diagnostics with text json github renderers @@ -146,6 +147,15 @@ upcoming one. 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`). +- Enclosing-parameter turbofish grounding: a turbofish whose type argument is + supplied by the enclosing generic scope (`identity::($v)` inside `wrap`, + `self::gen::()` / `Maker::wrap::()`, `$this->dup::()`) grounds **per + specialization** and runs, instead of being rejected by the emitted-marker + backstop. Freshly specialized bodies are re-grounded transitively so + multi-hop and mutually recursive forwards converge; a strictly-growing chain + (`grow` calling `grow::>`) is rejected as non-convergent + (`xphp.unconverged_method_specialization`). `compile` and `check` report this + identically. ### Anonymous templates @@ -254,6 +264,16 @@ upcoming one. - RFC-aligned call-site syntax (`Name::<...>` turbofish). - Empty turbofish (`Name::<>`) for all-defaults templates. +- **Type-argument inference (optional turbofish)**: a generic call or `new` + whose type parameters are fixed by the argument values drops the turbofish + — `identity(5)` infers `identity::`, `new Box($product)` infers + `new Box::` — compiling to the exact specialization the turbofish + would have selected. Free functions, static/instance methods, and `new`. + Where the arguments don't determine the type (a parameter only in the return + type, an unknown or conflicting argument type), the explicit turbofish is + still required. See the + [turbofish → inference](syntax/turbofish.md#type-argument-inference) tour and + [caveats](caveats.md#type-argument-inference-is-partial). ### Validation and diagnostics