From d7fe9a416c7a1a8273216b71250aa56057ae4169 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 11 Aug 2026 13:43:47 +0800 Subject: [PATCH 1/9] Enhance ContractVisitor to respect @typephp-ignore tags and update tests for inline variable validation --- src/Internal/ContractVisitor.php | 16 +++++++++-- tests/TypeChecking/DocblockIgnoreTagsTest.php | 28 +++++++++++++++---- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/Internal/ContractVisitor.php b/src/Internal/ContractVisitor.php index bc9c8f1..f30cd6e 100644 --- a/src/Internal/ContractVisitor.php +++ b/src/Internal/ContractVisitor.php @@ -5,6 +5,7 @@ namespace TypePHP\Internal; use PhpParser\Node; +use PhpParser\NodeTraverser; use PhpParser\NodeVisitorAbstract; use TypePHP\Internal\Visitor\FunctionContractInjector; use TypePHP\Internal\Visitor\NodeBuilder; @@ -26,9 +27,9 @@ public function __construct() /** * Traverses and transforms AST nodes during entry. * - * @return array|null + * @return array|int|null */ - public function enterNode(Node $node): array|null + public function enterNode(Node $node): array|int|null { if ($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod @@ -47,6 +48,15 @@ public function enterNode(Node $node): array|null } if ($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod) { + $doc = $node->getDocComment(); + if ($doc !== null) { + $docText = $doc->getText(); + $shouldRespectIgnore = (bool) (Config::get()['respect_ignore_tags'] ?? true); + if ($shouldRespectIgnore && (str_contains($docText, '@typephp-ignore') || str_contains($docText, '@typephp-disable'))) { + return NodeTraverser::DONT_TRAVERSE_CHILDREN; + } + } + FunctionContractInjector::inject($node); return null; @@ -204,4 +214,4 @@ private function extractDestructuringVariables(Node\Expr\List_|Node\Expr\Array_ return $vars; } -} +} \ No newline at end of file diff --git a/tests/TypeChecking/DocblockIgnoreTagsTest.php b/tests/TypeChecking/DocblockIgnoreTagsTest.php index 6cbce28..1b5b930 100644 --- a/tests/TypeChecking/DocblockIgnoreTagsTest.php +++ b/tests/TypeChecking/DocblockIgnoreTagsTest.php @@ -28,9 +28,8 @@ function testIgnoredFunction(int $id): int test('skips type-checking on method marked with @typephp-ignore while enforcing normal methods in same class', function () { $fixture = new IgnoredMethod(); - expect(fn () => $fixture->normalMethod(-5)) - ->toThrow(TypeError::class, 'positive-int') - ; + expect(fn() => $fixture->normalMethod(-5)) + ->toThrow(TypeError::class, 'positive-int'); expect($fixture->ignoredMethod(-100))->toBe(-100); }); @@ -38,9 +37,8 @@ function testIgnoredFunction(int $id): int test('skips type-checking on class property marked with @typephp-ignore', function () { $fixture = new IgnoredMethod(); - expect(fn () => $fixture->setNormalProperty(-5)) - ->toThrow(TypeError::class, 'Property') - ; + expect(fn() => $fixture->setNormalProperty(-5)) + ->toThrow(TypeError::class, 'Property'); $fixture->setIgnoredProperty(-5); expect($fixture->ignoredProperty)->toBe(-5); @@ -52,4 +50,22 @@ function testIgnoredFunction(int $id): int $result = $fileFixture->process(-500); expect($result)->toBe(-500); }); + + test('skips inline variable validation inside methods marked with @typephp-ignore', function () { + $fixture = new class() { + /** + * @typephp-ignore + * @param positive-int $id + */ + public function ignoredMethodWithInlineVar(int $id): bool + { + /** @var string */ + $string = 1; // Invalid type assignment, but skipped because of @typephp-ignore above! + + return true; + } + }; + + expect($fixture->ignoredMethodWithInlineVar(-500))->toBeTrue(); + }); }); From 1ece4ec09db862485b622c3440bb41541b65976f Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 11 Aug 2026 13:44:03 +0800 Subject: [PATCH 2/9] Fix formatting and improve clarity in README.md --- README.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ab97366..a52c340 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,9 @@ PHPStan Level MAX

------- +--- -TypePHP is a transparent, pure-PHP runtime type checker. You don't have to refactor a single line of your codebase, setup complex build toolchains, or compile C-extensions and simply run your existing code, and TypePHP will enforce your extended PHPDoc contracts (generics, array shapes, `key-of`/`value-of` extractions, and scalar refinements) dynamically at runtime. +TypePHP is a transparent, pure-PHP runtime type checker. You don't have to refactor a single line of your codebase, set up complex build toolchains, or compile C-extensions. Simply run your existing code, and TypePHP will enforce your extended PHPDoc contracts (generics, array shapes, `key-of`/`value-of` extractions, and scalar refinements) dynamically at runtime. **[Read the full TypePHP documentation »](https://typephp-php.github.io/typephp/)** @@ -29,15 +29,17 @@ All the documentation lives on the [typephp-php.github.io/typephp website](https * [Getting Started & Installation Guide](https://typephp-php.github.io/typephp/getting-started/installation) * [Quick Start Guide](https://typephp-php.github.io/typephp/getting-started/quick-start) -* [Architecture: How It Works](https://typephp-php.github.io/typephp/architecture/how-it-works) -* [Core Concepts: Function Contracts](https://typephp-php.github.io/typephp/core-concepts/function-contracts) -* [Core Concepts: Generics & Bounds](https://typephp-php.github.io/typephp/core-concepts/generics-and-bounds) +* [Configuration Guide](https://typephp-php.github.io/typephp/getting-started/configuration) +* [CLI Commands Reference](https://typephp-php.github.io/typephp/getting-started/cli-commands) +* [Enforcement Boundaries: Function Contracts](https://typephp-php.github.io/typephp/core-concepts/function-contracts) +* [Runtime Generics & Bounds](https://typephp-php.github.io/typephp/generics/generics-and-bounds) * [Supported Types: Arrays & Shapes](https://typephp-php.github.io/typephp/supported-types/arrays-and-shapes) -* [Troubleshooting & FAQ](https://typephp-php.github.io/typephp/advanced/troubleshooting) +* [Architecture: How It Works](https://typephp-php.github.io/typephp/advanced/how-it-works) +* [Troubleshooting & FAQ](https://typephp-php.github.io/typephp/troubleshooting) ## Inspiration -TypePHP is conceptually inspired by Python's [Beartype](https://github.com/beartype/beartype), but bringing transparent runtime type enforcement for type annotations to the PHP ecosystem without any decorators or attributes. +TypePHP is conceptually inspired by Python's [Beartype](https://github.com/beartype/beartype), bringing transparent runtime type enforcement for type annotations to the PHP ecosystem without any decorators or attributes. ## Sponsors From bb0172e9c03bd485d360ff824755e9a572b37206 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 11 Aug 2026 13:44:14 +0800 Subject: [PATCH 3/9] Enhance CommandRunner to handle unknown commands and validate PHP file extensions; add corresponding tests --- src/Command/CommandRunner.php | 43 +++++++++++++++++++++++++---- src/Command/RunCommand.php | 13 ++++++++- tests/Command/CommandRunnerTest.php | 39 ++++++++++++++++++++++++-- 3 files changed, 85 insertions(+), 10 deletions(-) diff --git a/src/Command/CommandRunner.php b/src/Command/CommandRunner.php index e7ab1fb..a9091ca 100644 --- a/src/Command/CommandRunner.php +++ b/src/Command/CommandRunner.php @@ -6,6 +6,14 @@ final class CommandRunner { + private const KNOWN_COMMANDS = [ + 'config:init', + 'cache:clear', + 'cache:warm', + 'cache:rebuild', + 'help', + ]; + /** * Parses CLI arguments and routes execution to the corresponding command class. * @@ -15,28 +23,51 @@ final class CommandRunner */ public static function run(array $args, $outputStream = STDOUT, $errorStream = STDERR): int { - $showHelp = \in_array('help', $args, true) || \in_array('typephp:help', $args, true) || \in_array('--help', $args, true) || \in_array('-h', $args, true); + $c = [CliFormatter::class, 'color']; + + $showHelp = \in_array('help', $args, true) + || \in_array('typephp:help', $args, true) + || \in_array('--help', $args, true) + || \in_array('-h', $args, true) + || $args === []; - if ($showHelp || $args === []) { + if ($showHelp) { return (new HelpCommand())->execute($args, $outputStream, $errorStream); } - if (\in_array('config:init', $args, true) || \in_array('init', $args, true)) { + $firstArg = $args[0] ?? ''; + + if ($firstArg === 'config:init' || $firstArg === 'init') { return (new ConfigInitCommand())->execute($args, $outputStream, $errorStream); } - if (\in_array('cache:rebuild', $args, true)) { + if ($firstArg === 'cache:rebuild') { return (new CacheRebuildCommand())->execute($args, $outputStream, $errorStream); } - if (\in_array('cache:clear', $args, true)) { + if ($firstArg === 'cache:clear') { return (new CacheClearCommand())->execute($args, $outputStream, $errorStream); } - if (\in_array('cache:warm', $args, true)) { + if ($firstArg === 'cache:warm') { return (new CacheWarmCommand())->execute($args, $outputStream, $errorStream); } + $hasFileExtension = str_contains(basename($firstArg), '.'); + $isFileTarget = file_exists($firstArg) || $hasFileExtension; + + if (! $isFileTarget) { + fwrite($errorStream, "\n " . $c(' TYPEPHP ', 'badge_red') . ' ' . $c('Error', 'bold') . "\n\n"); + fwrite($errorStream, ' ' . $c('✗', 'red') . ' Command ' . $c('"' . $firstArg . '"', 'bold') . " is not defined.\n\n"); + fwrite($errorStream, ' ' . $c('Did you mean one of these?', 'yellow') . "\n"); + foreach (self::KNOWN_COMMANDS as $cmd) { + fwrite($errorStream, ' ' . $c('•', 'cyan') . ' ' . $cmd . "\n"); + } + fwrite($errorStream, "\n"); + + return 1; + } + return (new RunCommand())->execute($args, $outputStream, $errorStream); } } diff --git a/src/Command/RunCommand.php b/src/Command/RunCommand.php index a576643..67bc72c 100644 --- a/src/Command/RunCommand.php +++ b/src/Command/RunCommand.php @@ -11,6 +11,8 @@ */ final class RunCommand implements CommandInterface { + private const VALID_PHP_EXTENSIONS = ['php', 'phtml', 'php5', 'php7', 'php8', 'phps']; + public function execute(array $args, $outputStream = STDOUT, $errorStream = STDERR): int { $c = [CliFormatter::class, 'color']; @@ -21,6 +23,15 @@ public function execute(array $args, $outputStream = STDOUT, $errorStream = STDE foreach ($args as $arg) { if (! str_starts_with($arg, '--') && ! str_starts_with($arg, '-')) { $givenTargetCandidate = $arg; + $ext = strtolower(pathinfo($arg, PATHINFO_EXTENSION)); + + if ($ext !== '' && ! \in_array($ext, self::VALID_PHP_EXTENSIONS, true)) { + fwrite($errorStream, "\n " . $c(' TYPEPHP ', 'badge_red') . ' ' . $c('Error', 'bold') . "\n\n"); + fwrite($errorStream, ' ' . $c('✗', 'red') . ' Target file ' . $c('"' . $arg . '"', 'bold') . " is not a PHP script file. TypePHP can only execute PHP files.\n\n"); + + return 1; + } + if (file_exists($arg)) { $target = $arg; } @@ -31,7 +42,7 @@ public function execute(array $args, $outputStream = STDOUT, $errorStream = STDE if ($givenTargetCandidate !== null && $target === null) { fwrite($errorStream, "\n " . $c(' TYPEPHP ', 'badge_red') . ' ' . $c('Error', 'bold') . "\n\n"); - fwrite($errorStream, ' ' . $c('✗', 'red') . ' Target file ' . $c('"' . $givenTargetCandidate . '"', 'bold') . " does not exist or is not readable.\n\n"); + fwrite($errorStream, ' ' . $c('✗', 'red') . ' Target script file ' . $c('"' . $givenTargetCandidate . '"', 'bold') . " does not exist or is not readable.\n\n"); return 1; } diff --git a/tests/Command/CommandRunnerTest.php b/tests/Command/CommandRunnerTest.php index bd40b0c..30ee72f 100644 --- a/tests/Command/CommandRunnerTest.php +++ b/tests/Command/CommandRunnerTest.php @@ -54,16 +54,49 @@ expect($exitCode)->toBe(0); }); - test('returns exit code 1 when target file does not exist', function () { + test('returns exit code 1 and detects unknown command for typo like helps', function () { + $stream = fopen('php://memory', 'r+'); + $exitCode = CommandRunner::run(['helps'], $stream, $stream); + + rewind($stream); + $rawOutput = stream_get_contents($stream); + fclose($stream); + + $output = preg_replace('/\x1b\[[0-9;]*m/', '', $rawOutput); + + expect($exitCode)->toBe(1) + ->and($output)->toContain('Command "helps" is not defined') + ->and($output)->toContain('Did you mean one of these?') + ; + }); + + test('returns exit code 1 and warns when target file has non-PHP extension', function () { + $stream = fopen('php://memory', 'r+'); + $exitCode = CommandRunner::run(['index.js'], $stream, $stream); + + rewind($stream); + $rawOutput = stream_get_contents($stream); + fclose($stream); + + $output = preg_replace('/\x1b\[[0-9;]*m/', '', $rawOutput); + + expect($exitCode)->toBe(1) + ->and($output)->toContain('Target file "index.js" is not a PHP script file') + ; + }); + + test('returns exit code 1 when target script file ending in .php does not exist', function () { $stream = fopen('php://memory', 'r+'); $exitCode = CommandRunner::run(['non_existent_script_123.php'], $stream, $stream); rewind($stream); - $output = stream_get_contents($stream); + $rawOutput = stream_get_contents($stream); fclose($stream); + $output = preg_replace('/\x1b\[[0-9;]*m/', '', $rawOutput); + expect($exitCode)->toBe(1) - ->and($output)->toContain('Error') + ->and($output)->toContain('Target script file "non_existent_script_123.php" does not exist or is not readable') ; }); }); From 5be4d61148577cbb5e97206a108c5b8154164f7d Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 11 Aug 2026 14:00:07 +0800 Subject: [PATCH 4/9] Reorganize test suites for typechecking --- reorganize-tests.sh | 67 +++++++++++++++++++ .../AdvancedTypesAndEnumsTest.php | 0 .../ArrayAndListTypesTest.php | 0 .../KeyOfValueOfTest.php | 0 .../OffsetAccessTest.php | 0 .../UnionAndIntersectionTypesTest.php | 0 .../BlockScopeShadowingTest.php | 0 .../ClosureVariableScopeTest.php | 0 .../ExtendedReturnTypesTest.php | 0 .../IgnoreUnrecognizeDoctypeTest.php | 0 .../ImportedFunctionsTest.php | 0 .../InlineVariableValidationTest.php | 0 .../ListDestructuringTest.php | 0 .../{ => Boundaries}/NamedArgumentsTest.php | 0 .../{ => Boundaries}/ParamContractsTest.php | 0 .../{ => Boundaries}/PropertyHooksTest.php | 0 .../PropertyValidationTest.php | 0 .../{ => Boundaries}/ReturnContractsTest.php | 0 .../VarAnnotationPrebindingTest.php | 0 .../CallableAndClosureContractsTest.php | 0 .../LazyIteratorsAndGeneratorsTest.php | 0 .../BoundaryConfigTest.php | 0 .../DocblockIgnoreTagsTest.php | 0 .../RecursionAndExceptionLeakTest.php | 0 .../RespectIgnoreTagsConfigTest.php | 0 .../AdvancedGenericsAndShapesTest.php | 0 .../CloneGenericInstanceTest.php | 0 .../ConditionalTypesWithGenericsTest.php | 0 .../DefaultTemplateTypesTest.php | 0 .../GenericPropertyHooksTest.php | 0 .../GenericsAndInheritanceTest.php | 0 ...nheritedGenericCloneAndConditionalTest.php | 0 .../AttributeConstructorInheritanceTest.php | 0 .../DeepInheritanceTest.php | 0 .../LiskovAndVendorIsolationTest.php | 0 .../NamespaceResolutionTest.php | 0 .../OopInheritanceTest.php | 0 .../PhpAttributesCoexistenceTest.php | 0 .../{ => Scalars}/ExtendedScalarTypesTest.php | 0 .../{ => Scalars}/FloatLiteralsTest.php | 0 .../{ => Scalars}/IntMaskTest.php | 0 .../NewScalarAndPseudoTypesTest.php | 0 .../UppercaseAndArrayKeyTest.php | 0 43 files changed, 67 insertions(+) create mode 100644 reorganize-tests.sh rename tests/TypeChecking/{ => ArraysAndShapes}/AdvancedTypesAndEnumsTest.php (100%) rename tests/TypeChecking/{ => ArraysAndShapes}/ArrayAndListTypesTest.php (100%) rename tests/TypeChecking/{ => ArraysAndShapes}/KeyOfValueOfTest.php (100%) rename tests/TypeChecking/{ => ArraysAndShapes}/OffsetAccessTest.php (100%) rename tests/TypeChecking/{ => ArraysAndShapes}/UnionAndIntersectionTypesTest.php (100%) rename tests/TypeChecking/{ => Boundaries}/BlockScopeShadowingTest.php (100%) rename tests/TypeChecking/{ => Boundaries}/ClosureVariableScopeTest.php (100%) rename tests/TypeChecking/{ => Boundaries}/ExtendedReturnTypesTest.php (100%) rename tests/TypeChecking/{ => Boundaries}/IgnoreUnrecognizeDoctypeTest.php (100%) rename tests/TypeChecking/{ => Boundaries}/ImportedFunctionsTest.php (100%) rename tests/TypeChecking/{ => Boundaries}/InlineVariableValidationTest.php (100%) rename tests/TypeChecking/{ => Boundaries}/ListDestructuringTest.php (100%) rename tests/TypeChecking/{ => Boundaries}/NamedArgumentsTest.php (100%) rename tests/TypeChecking/{ => Boundaries}/ParamContractsTest.php (100%) rename tests/TypeChecking/{ => Boundaries}/PropertyHooksTest.php (100%) rename tests/TypeChecking/{ => Boundaries}/PropertyValidationTest.php (100%) rename tests/TypeChecking/{ => Boundaries}/ReturnContractsTest.php (100%) rename tests/TypeChecking/{ => Boundaries}/VarAnnotationPrebindingTest.php (100%) rename tests/TypeChecking/{ => CallablesAndIterators}/CallableAndClosureContractsTest.php (100%) rename tests/TypeChecking/{ => CallablesAndIterators}/LazyIteratorsAndGeneratorsTest.php (100%) rename tests/TypeChecking/{ => Configuration}/BoundaryConfigTest.php (100%) rename tests/TypeChecking/{ => Configuration}/DocblockIgnoreTagsTest.php (100%) rename tests/TypeChecking/{ => Configuration}/RecursionAndExceptionLeakTest.php (100%) rename tests/TypeChecking/{ => Configuration}/RespectIgnoreTagsConfigTest.php (100%) rename tests/TypeChecking/{ => Generics}/AdvancedGenericsAndShapesTest.php (100%) rename tests/TypeChecking/{ => Generics}/CloneGenericInstanceTest.php (100%) rename tests/TypeChecking/{ => Generics}/ConditionalTypesWithGenericsTest.php (100%) rename tests/TypeChecking/{ => Generics}/DefaultTemplateTypesTest.php (100%) rename tests/TypeChecking/{ => Generics}/GenericPropertyHooksTest.php (100%) rename tests/TypeChecking/{ => Generics}/GenericsAndInheritanceTest.php (100%) rename tests/TypeChecking/{ => Generics}/InheritedGenericCloneAndConditionalTest.php (100%) rename tests/TypeChecking/{ => InheritanceAndAttributes}/AttributeConstructorInheritanceTest.php (100%) rename tests/TypeChecking/{ => InheritanceAndAttributes}/DeepInheritanceTest.php (100%) rename tests/TypeChecking/{ => InheritanceAndAttributes}/LiskovAndVendorIsolationTest.php (100%) rename tests/TypeChecking/{ => InheritanceAndAttributes}/NamespaceResolutionTest.php (100%) rename tests/TypeChecking/{ => InheritanceAndAttributes}/OopInheritanceTest.php (100%) rename tests/TypeChecking/{ => InheritanceAndAttributes}/PhpAttributesCoexistenceTest.php (100%) rename tests/TypeChecking/{ => Scalars}/ExtendedScalarTypesTest.php (100%) rename tests/TypeChecking/{ => Scalars}/FloatLiteralsTest.php (100%) rename tests/TypeChecking/{ => Scalars}/IntMaskTest.php (100%) rename tests/TypeChecking/{ => Scalars}/NewScalarAndPseudoTypesTest.php (100%) rename tests/TypeChecking/{ => Scalars}/UppercaseAndArrayKeyTest.php (100%) diff --git a/reorganize-tests.sh b/reorganize-tests.sh new file mode 100644 index 0000000..2f01f16 --- /dev/null +++ b/reorganize-tests.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +# Create Directories +mkdir -p tests/TypeChecking/Boundaries +mkdir -p tests/TypeChecking/Scalars +mkdir -p tests/TypeChecking/ArraysAndShapes +mkdir -p tests/TypeChecking/Generics +mkdir -p tests/TypeChecking/CallablesAndIterators +mkdir -p tests/TypeChecking/InheritanceAndAttributes +mkdir -p tests/TypeChecking/Configuration + +# Move Boundaries Tests +git mv tests/TypeChecking/ParamContractsTest.php tests/TypeChecking/Boundaries/ +git mv tests/TypeChecking/ReturnContractsTest.php tests/TypeChecking/Boundaries/ +git mv tests/TypeChecking/ExtendedReturnTypesTest.php tests/TypeChecking/Boundaries/ +git mv tests/TypeChecking/PropertyValidationTest.php tests/TypeChecking/Boundaries/ +git mv tests/TypeChecking/PropertyHooksTest.php tests/TypeChecking/Boundaries/ +git mv tests/TypeChecking/InlineVariableValidationTest.php tests/TypeChecking/Boundaries/ +git mv tests/TypeChecking/BlockScopeShadowingTest.php tests/TypeChecking/Boundaries/ +git mv tests/TypeChecking/ClosureVariableScopeTest.php tests/TypeChecking/Boundaries/ +git mv tests/TypeChecking/ListDestructuringTest.php tests/TypeChecking/Boundaries/ +git mv tests/TypeChecking/VarAnnotationPrebindingTest.php tests/TypeChecking/Boundaries/ +git mv tests/TypeChecking/ImportedFunctionsTest.php tests/TypeChecking/Boundaries/ +git mv tests/TypeChecking/NamedArgumentsTest.php tests/TypeChecking/Boundaries/ + +# Move Scalars Tests +git mv tests/TypeChecking/ExtendedScalarTypesTest.php tests/TypeChecking/Scalars/ +git mv tests/TypeChecking/FloatLiteralsTest.php tests/TypeChecking/Scalars/ +git mv tests/TypeChecking/NewScalarAndPseudoTypesTest.php tests/TypeChecking/Scalars/ +git mv tests/TypeChecking/UppercaseAndArrayKeyTest.php tests/TypeChecking/Scalars/ +git mv tests/TypeChecking/IntMaskTest.php tests/TypeChecking/Scalars/ + +# Move ArraysAndShapes Tests +git mv tests/TypeChecking/ArrayAndListTypesTest.php tests/TypeChecking/ArraysAndShapes/ +git mv tests/TypeChecking/UnionAndIntersectionTypesTest.php tests/TypeChecking/ArraysAndShapes/ +git mv tests/TypeChecking/KeyOfValueOfTest.php tests/TypeChecking/ArraysAndShapes/ +git mv tests/TypeChecking/OffsetAccessTest.php tests/TypeChecking/ArraysAndShapes/ +git mv tests/TypeChecking/AdvancedTypesAndEnumsTest.php tests/TypeChecking/ArraysAndShapes/ + +# Move Generics Tests +git mv tests/TypeChecking/GenericsAndInheritanceTest.php tests/TypeChecking/Generics/ +git mv tests/TypeChecking/DefaultTemplateTypesTest.php tests/TypeChecking/Generics/ +git mv tests/TypeChecking/CloneGenericInstanceTest.php tests/TypeChecking/Generics/ +git mv tests/TypeChecking/GenericPropertyHooksTest.php tests/TypeChecking/Generics/ +git mv tests/TypeChecking/ConditionalTypesWithGenericsTest.php tests/TypeChecking/Generics/ +git mv tests/TypeChecking/InheritedGenericCloneAndConditionalTest.php tests/TypeChecking/Generics/ +git mv tests/TypeChecking/AdvancedGenericsAndShapesTest.php tests/TypeChecking/Generics/ + +# Move CallablesAndIterators Tests +git mv tests/TypeChecking/CallableAndClosureContractsTest.php tests/TypeChecking/CallablesAndIterators/ +git mv tests/TypeChecking/LazyIteratorsAndGeneratorsTest.php tests/TypeChecking/CallablesAndIterators/ + +# Move InheritanceAndAttributes Tests +git mv tests/TypeChecking/OopInheritanceTest.php tests/TypeChecking/InheritanceAndAttributes/ +git mv tests/TypeChecking/DeepInheritanceTest.php tests/TypeChecking/InheritanceAndAttributes/ +git mv tests/TypeChecking/LiskovAndVendorIsolationTest.php tests/TypeChecking/InheritanceAndAttributes/ +git mv tests/TypeChecking/AttributeConstructorInheritanceTest.php tests/TypeChecking/InheritanceAndAttributes/ +git mv tests/TypeChecking/PhpAttributesCoexistenceTest.php tests/TypeChecking/InheritanceAndAttributes/ +git mv tests/TypeChecking/NamespaceResolutionTest.php tests/TypeChecking/InheritanceAndAttributes/ + +# Move Configuration Tests +git mv tests/TypeChecking/BoundaryConfigTest.php tests/TypeChecking/Configuration/ +git mv tests/TypeChecking/RespectIgnoreTagsConfigTest.php tests/TypeChecking/Configuration/ +git mv tests/TypeChecking/DocblockIgnoreTagsTest.php tests/TypeChecking/Configuration/ +git mv tests/TypeChecking/RecursionAndExceptionLeakTest.php tests/TypeChecking/Configuration/ + +echo "Reorganization Complete!" \ No newline at end of file diff --git a/tests/TypeChecking/AdvancedTypesAndEnumsTest.php b/tests/TypeChecking/ArraysAndShapes/AdvancedTypesAndEnumsTest.php similarity index 100% rename from tests/TypeChecking/AdvancedTypesAndEnumsTest.php rename to tests/TypeChecking/ArraysAndShapes/AdvancedTypesAndEnumsTest.php diff --git a/tests/TypeChecking/ArrayAndListTypesTest.php b/tests/TypeChecking/ArraysAndShapes/ArrayAndListTypesTest.php similarity index 100% rename from tests/TypeChecking/ArrayAndListTypesTest.php rename to tests/TypeChecking/ArraysAndShapes/ArrayAndListTypesTest.php diff --git a/tests/TypeChecking/KeyOfValueOfTest.php b/tests/TypeChecking/ArraysAndShapes/KeyOfValueOfTest.php similarity index 100% rename from tests/TypeChecking/KeyOfValueOfTest.php rename to tests/TypeChecking/ArraysAndShapes/KeyOfValueOfTest.php diff --git a/tests/TypeChecking/OffsetAccessTest.php b/tests/TypeChecking/ArraysAndShapes/OffsetAccessTest.php similarity index 100% rename from tests/TypeChecking/OffsetAccessTest.php rename to tests/TypeChecking/ArraysAndShapes/OffsetAccessTest.php diff --git a/tests/TypeChecking/UnionAndIntersectionTypesTest.php b/tests/TypeChecking/ArraysAndShapes/UnionAndIntersectionTypesTest.php similarity index 100% rename from tests/TypeChecking/UnionAndIntersectionTypesTest.php rename to tests/TypeChecking/ArraysAndShapes/UnionAndIntersectionTypesTest.php diff --git a/tests/TypeChecking/BlockScopeShadowingTest.php b/tests/TypeChecking/Boundaries/BlockScopeShadowingTest.php similarity index 100% rename from tests/TypeChecking/BlockScopeShadowingTest.php rename to tests/TypeChecking/Boundaries/BlockScopeShadowingTest.php diff --git a/tests/TypeChecking/ClosureVariableScopeTest.php b/tests/TypeChecking/Boundaries/ClosureVariableScopeTest.php similarity index 100% rename from tests/TypeChecking/ClosureVariableScopeTest.php rename to tests/TypeChecking/Boundaries/ClosureVariableScopeTest.php diff --git a/tests/TypeChecking/ExtendedReturnTypesTest.php b/tests/TypeChecking/Boundaries/ExtendedReturnTypesTest.php similarity index 100% rename from tests/TypeChecking/ExtendedReturnTypesTest.php rename to tests/TypeChecking/Boundaries/ExtendedReturnTypesTest.php diff --git a/tests/TypeChecking/IgnoreUnrecognizeDoctypeTest.php b/tests/TypeChecking/Boundaries/IgnoreUnrecognizeDoctypeTest.php similarity index 100% rename from tests/TypeChecking/IgnoreUnrecognizeDoctypeTest.php rename to tests/TypeChecking/Boundaries/IgnoreUnrecognizeDoctypeTest.php diff --git a/tests/TypeChecking/ImportedFunctionsTest.php b/tests/TypeChecking/Boundaries/ImportedFunctionsTest.php similarity index 100% rename from tests/TypeChecking/ImportedFunctionsTest.php rename to tests/TypeChecking/Boundaries/ImportedFunctionsTest.php diff --git a/tests/TypeChecking/InlineVariableValidationTest.php b/tests/TypeChecking/Boundaries/InlineVariableValidationTest.php similarity index 100% rename from tests/TypeChecking/InlineVariableValidationTest.php rename to tests/TypeChecking/Boundaries/InlineVariableValidationTest.php diff --git a/tests/TypeChecking/ListDestructuringTest.php b/tests/TypeChecking/Boundaries/ListDestructuringTest.php similarity index 100% rename from tests/TypeChecking/ListDestructuringTest.php rename to tests/TypeChecking/Boundaries/ListDestructuringTest.php diff --git a/tests/TypeChecking/NamedArgumentsTest.php b/tests/TypeChecking/Boundaries/NamedArgumentsTest.php similarity index 100% rename from tests/TypeChecking/NamedArgumentsTest.php rename to tests/TypeChecking/Boundaries/NamedArgumentsTest.php diff --git a/tests/TypeChecking/ParamContractsTest.php b/tests/TypeChecking/Boundaries/ParamContractsTest.php similarity index 100% rename from tests/TypeChecking/ParamContractsTest.php rename to tests/TypeChecking/Boundaries/ParamContractsTest.php diff --git a/tests/TypeChecking/PropertyHooksTest.php b/tests/TypeChecking/Boundaries/PropertyHooksTest.php similarity index 100% rename from tests/TypeChecking/PropertyHooksTest.php rename to tests/TypeChecking/Boundaries/PropertyHooksTest.php diff --git a/tests/TypeChecking/PropertyValidationTest.php b/tests/TypeChecking/Boundaries/PropertyValidationTest.php similarity index 100% rename from tests/TypeChecking/PropertyValidationTest.php rename to tests/TypeChecking/Boundaries/PropertyValidationTest.php diff --git a/tests/TypeChecking/ReturnContractsTest.php b/tests/TypeChecking/Boundaries/ReturnContractsTest.php similarity index 100% rename from tests/TypeChecking/ReturnContractsTest.php rename to tests/TypeChecking/Boundaries/ReturnContractsTest.php diff --git a/tests/TypeChecking/VarAnnotationPrebindingTest.php b/tests/TypeChecking/Boundaries/VarAnnotationPrebindingTest.php similarity index 100% rename from tests/TypeChecking/VarAnnotationPrebindingTest.php rename to tests/TypeChecking/Boundaries/VarAnnotationPrebindingTest.php diff --git a/tests/TypeChecking/CallableAndClosureContractsTest.php b/tests/TypeChecking/CallablesAndIterators/CallableAndClosureContractsTest.php similarity index 100% rename from tests/TypeChecking/CallableAndClosureContractsTest.php rename to tests/TypeChecking/CallablesAndIterators/CallableAndClosureContractsTest.php diff --git a/tests/TypeChecking/LazyIteratorsAndGeneratorsTest.php b/tests/TypeChecking/CallablesAndIterators/LazyIteratorsAndGeneratorsTest.php similarity index 100% rename from tests/TypeChecking/LazyIteratorsAndGeneratorsTest.php rename to tests/TypeChecking/CallablesAndIterators/LazyIteratorsAndGeneratorsTest.php diff --git a/tests/TypeChecking/BoundaryConfigTest.php b/tests/TypeChecking/Configuration/BoundaryConfigTest.php similarity index 100% rename from tests/TypeChecking/BoundaryConfigTest.php rename to tests/TypeChecking/Configuration/BoundaryConfigTest.php diff --git a/tests/TypeChecking/DocblockIgnoreTagsTest.php b/tests/TypeChecking/Configuration/DocblockIgnoreTagsTest.php similarity index 100% rename from tests/TypeChecking/DocblockIgnoreTagsTest.php rename to tests/TypeChecking/Configuration/DocblockIgnoreTagsTest.php diff --git a/tests/TypeChecking/RecursionAndExceptionLeakTest.php b/tests/TypeChecking/Configuration/RecursionAndExceptionLeakTest.php similarity index 100% rename from tests/TypeChecking/RecursionAndExceptionLeakTest.php rename to tests/TypeChecking/Configuration/RecursionAndExceptionLeakTest.php diff --git a/tests/TypeChecking/RespectIgnoreTagsConfigTest.php b/tests/TypeChecking/Configuration/RespectIgnoreTagsConfigTest.php similarity index 100% rename from tests/TypeChecking/RespectIgnoreTagsConfigTest.php rename to tests/TypeChecking/Configuration/RespectIgnoreTagsConfigTest.php diff --git a/tests/TypeChecking/AdvancedGenericsAndShapesTest.php b/tests/TypeChecking/Generics/AdvancedGenericsAndShapesTest.php similarity index 100% rename from tests/TypeChecking/AdvancedGenericsAndShapesTest.php rename to tests/TypeChecking/Generics/AdvancedGenericsAndShapesTest.php diff --git a/tests/TypeChecking/CloneGenericInstanceTest.php b/tests/TypeChecking/Generics/CloneGenericInstanceTest.php similarity index 100% rename from tests/TypeChecking/CloneGenericInstanceTest.php rename to tests/TypeChecking/Generics/CloneGenericInstanceTest.php diff --git a/tests/TypeChecking/ConditionalTypesWithGenericsTest.php b/tests/TypeChecking/Generics/ConditionalTypesWithGenericsTest.php similarity index 100% rename from tests/TypeChecking/ConditionalTypesWithGenericsTest.php rename to tests/TypeChecking/Generics/ConditionalTypesWithGenericsTest.php diff --git a/tests/TypeChecking/DefaultTemplateTypesTest.php b/tests/TypeChecking/Generics/DefaultTemplateTypesTest.php similarity index 100% rename from tests/TypeChecking/DefaultTemplateTypesTest.php rename to tests/TypeChecking/Generics/DefaultTemplateTypesTest.php diff --git a/tests/TypeChecking/GenericPropertyHooksTest.php b/tests/TypeChecking/Generics/GenericPropertyHooksTest.php similarity index 100% rename from tests/TypeChecking/GenericPropertyHooksTest.php rename to tests/TypeChecking/Generics/GenericPropertyHooksTest.php diff --git a/tests/TypeChecking/GenericsAndInheritanceTest.php b/tests/TypeChecking/Generics/GenericsAndInheritanceTest.php similarity index 100% rename from tests/TypeChecking/GenericsAndInheritanceTest.php rename to tests/TypeChecking/Generics/GenericsAndInheritanceTest.php diff --git a/tests/TypeChecking/InheritedGenericCloneAndConditionalTest.php b/tests/TypeChecking/Generics/InheritedGenericCloneAndConditionalTest.php similarity index 100% rename from tests/TypeChecking/InheritedGenericCloneAndConditionalTest.php rename to tests/TypeChecking/Generics/InheritedGenericCloneAndConditionalTest.php diff --git a/tests/TypeChecking/AttributeConstructorInheritanceTest.php b/tests/TypeChecking/InheritanceAndAttributes/AttributeConstructorInheritanceTest.php similarity index 100% rename from tests/TypeChecking/AttributeConstructorInheritanceTest.php rename to tests/TypeChecking/InheritanceAndAttributes/AttributeConstructorInheritanceTest.php diff --git a/tests/TypeChecking/DeepInheritanceTest.php b/tests/TypeChecking/InheritanceAndAttributes/DeepInheritanceTest.php similarity index 100% rename from tests/TypeChecking/DeepInheritanceTest.php rename to tests/TypeChecking/InheritanceAndAttributes/DeepInheritanceTest.php diff --git a/tests/TypeChecking/LiskovAndVendorIsolationTest.php b/tests/TypeChecking/InheritanceAndAttributes/LiskovAndVendorIsolationTest.php similarity index 100% rename from tests/TypeChecking/LiskovAndVendorIsolationTest.php rename to tests/TypeChecking/InheritanceAndAttributes/LiskovAndVendorIsolationTest.php diff --git a/tests/TypeChecking/NamespaceResolutionTest.php b/tests/TypeChecking/InheritanceAndAttributes/NamespaceResolutionTest.php similarity index 100% rename from tests/TypeChecking/NamespaceResolutionTest.php rename to tests/TypeChecking/InheritanceAndAttributes/NamespaceResolutionTest.php diff --git a/tests/TypeChecking/OopInheritanceTest.php b/tests/TypeChecking/InheritanceAndAttributes/OopInheritanceTest.php similarity index 100% rename from tests/TypeChecking/OopInheritanceTest.php rename to tests/TypeChecking/InheritanceAndAttributes/OopInheritanceTest.php diff --git a/tests/TypeChecking/PhpAttributesCoexistenceTest.php b/tests/TypeChecking/InheritanceAndAttributes/PhpAttributesCoexistenceTest.php similarity index 100% rename from tests/TypeChecking/PhpAttributesCoexistenceTest.php rename to tests/TypeChecking/InheritanceAndAttributes/PhpAttributesCoexistenceTest.php diff --git a/tests/TypeChecking/ExtendedScalarTypesTest.php b/tests/TypeChecking/Scalars/ExtendedScalarTypesTest.php similarity index 100% rename from tests/TypeChecking/ExtendedScalarTypesTest.php rename to tests/TypeChecking/Scalars/ExtendedScalarTypesTest.php diff --git a/tests/TypeChecking/FloatLiteralsTest.php b/tests/TypeChecking/Scalars/FloatLiteralsTest.php similarity index 100% rename from tests/TypeChecking/FloatLiteralsTest.php rename to tests/TypeChecking/Scalars/FloatLiteralsTest.php diff --git a/tests/TypeChecking/IntMaskTest.php b/tests/TypeChecking/Scalars/IntMaskTest.php similarity index 100% rename from tests/TypeChecking/IntMaskTest.php rename to tests/TypeChecking/Scalars/IntMaskTest.php diff --git a/tests/TypeChecking/NewScalarAndPseudoTypesTest.php b/tests/TypeChecking/Scalars/NewScalarAndPseudoTypesTest.php similarity index 100% rename from tests/TypeChecking/NewScalarAndPseudoTypesTest.php rename to tests/TypeChecking/Scalars/NewScalarAndPseudoTypesTest.php diff --git a/tests/TypeChecking/UppercaseAndArrayKeyTest.php b/tests/TypeChecking/Scalars/UppercaseAndArrayKeyTest.php similarity index 100% rename from tests/TypeChecking/UppercaseAndArrayKeyTest.php rename to tests/TypeChecking/Scalars/UppercaseAndArrayKeyTest.php From 6ba90a423e4b5edec82c3949f47f4bc5f360d551 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 11 Aug 2026 14:00:15 +0800 Subject: [PATCH 5/9] Remove reorganize-tests.sh script for test directory restructuring --- reorganize-tests.sh | 67 --------------------------------------------- 1 file changed, 67 deletions(-) delete mode 100644 reorganize-tests.sh diff --git a/reorganize-tests.sh b/reorganize-tests.sh deleted file mode 100644 index 2f01f16..0000000 --- a/reorganize-tests.sh +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env bash - -# Create Directories -mkdir -p tests/TypeChecking/Boundaries -mkdir -p tests/TypeChecking/Scalars -mkdir -p tests/TypeChecking/ArraysAndShapes -mkdir -p tests/TypeChecking/Generics -mkdir -p tests/TypeChecking/CallablesAndIterators -mkdir -p tests/TypeChecking/InheritanceAndAttributes -mkdir -p tests/TypeChecking/Configuration - -# Move Boundaries Tests -git mv tests/TypeChecking/ParamContractsTest.php tests/TypeChecking/Boundaries/ -git mv tests/TypeChecking/ReturnContractsTest.php tests/TypeChecking/Boundaries/ -git mv tests/TypeChecking/ExtendedReturnTypesTest.php tests/TypeChecking/Boundaries/ -git mv tests/TypeChecking/PropertyValidationTest.php tests/TypeChecking/Boundaries/ -git mv tests/TypeChecking/PropertyHooksTest.php tests/TypeChecking/Boundaries/ -git mv tests/TypeChecking/InlineVariableValidationTest.php tests/TypeChecking/Boundaries/ -git mv tests/TypeChecking/BlockScopeShadowingTest.php tests/TypeChecking/Boundaries/ -git mv tests/TypeChecking/ClosureVariableScopeTest.php tests/TypeChecking/Boundaries/ -git mv tests/TypeChecking/ListDestructuringTest.php tests/TypeChecking/Boundaries/ -git mv tests/TypeChecking/VarAnnotationPrebindingTest.php tests/TypeChecking/Boundaries/ -git mv tests/TypeChecking/ImportedFunctionsTest.php tests/TypeChecking/Boundaries/ -git mv tests/TypeChecking/NamedArgumentsTest.php tests/TypeChecking/Boundaries/ - -# Move Scalars Tests -git mv tests/TypeChecking/ExtendedScalarTypesTest.php tests/TypeChecking/Scalars/ -git mv tests/TypeChecking/FloatLiteralsTest.php tests/TypeChecking/Scalars/ -git mv tests/TypeChecking/NewScalarAndPseudoTypesTest.php tests/TypeChecking/Scalars/ -git mv tests/TypeChecking/UppercaseAndArrayKeyTest.php tests/TypeChecking/Scalars/ -git mv tests/TypeChecking/IntMaskTest.php tests/TypeChecking/Scalars/ - -# Move ArraysAndShapes Tests -git mv tests/TypeChecking/ArrayAndListTypesTest.php tests/TypeChecking/ArraysAndShapes/ -git mv tests/TypeChecking/UnionAndIntersectionTypesTest.php tests/TypeChecking/ArraysAndShapes/ -git mv tests/TypeChecking/KeyOfValueOfTest.php tests/TypeChecking/ArraysAndShapes/ -git mv tests/TypeChecking/OffsetAccessTest.php tests/TypeChecking/ArraysAndShapes/ -git mv tests/TypeChecking/AdvancedTypesAndEnumsTest.php tests/TypeChecking/ArraysAndShapes/ - -# Move Generics Tests -git mv tests/TypeChecking/GenericsAndInheritanceTest.php tests/TypeChecking/Generics/ -git mv tests/TypeChecking/DefaultTemplateTypesTest.php tests/TypeChecking/Generics/ -git mv tests/TypeChecking/CloneGenericInstanceTest.php tests/TypeChecking/Generics/ -git mv tests/TypeChecking/GenericPropertyHooksTest.php tests/TypeChecking/Generics/ -git mv tests/TypeChecking/ConditionalTypesWithGenericsTest.php tests/TypeChecking/Generics/ -git mv tests/TypeChecking/InheritedGenericCloneAndConditionalTest.php tests/TypeChecking/Generics/ -git mv tests/TypeChecking/AdvancedGenericsAndShapesTest.php tests/TypeChecking/Generics/ - -# Move CallablesAndIterators Tests -git mv tests/TypeChecking/CallableAndClosureContractsTest.php tests/TypeChecking/CallablesAndIterators/ -git mv tests/TypeChecking/LazyIteratorsAndGeneratorsTest.php tests/TypeChecking/CallablesAndIterators/ - -# Move InheritanceAndAttributes Tests -git mv tests/TypeChecking/OopInheritanceTest.php tests/TypeChecking/InheritanceAndAttributes/ -git mv tests/TypeChecking/DeepInheritanceTest.php tests/TypeChecking/InheritanceAndAttributes/ -git mv tests/TypeChecking/LiskovAndVendorIsolationTest.php tests/TypeChecking/InheritanceAndAttributes/ -git mv tests/TypeChecking/AttributeConstructorInheritanceTest.php tests/TypeChecking/InheritanceAndAttributes/ -git mv tests/TypeChecking/PhpAttributesCoexistenceTest.php tests/TypeChecking/InheritanceAndAttributes/ -git mv tests/TypeChecking/NamespaceResolutionTest.php tests/TypeChecking/InheritanceAndAttributes/ - -# Move Configuration Tests -git mv tests/TypeChecking/BoundaryConfigTest.php tests/TypeChecking/Configuration/ -git mv tests/TypeChecking/RespectIgnoreTagsConfigTest.php tests/TypeChecking/Configuration/ -git mv tests/TypeChecking/DocblockIgnoreTagsTest.php tests/TypeChecking/Configuration/ -git mv tests/TypeChecking/RecursionAndExceptionLeakTest.php tests/TypeChecking/Configuration/ - -echo "Reorganization Complete!" \ No newline at end of file From dfc2fa86edb771606d1019987d86bc9eaad6c867 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 11 Aug 2026 15:26:19 +0800 Subject: [PATCH 6/9] Add class constant key tests and fixtures for array shape validation --- src/Internal/ContractVisitor.php | 2 +- src/Resolver/SpecialTypeResolver.php | 746 ++++++++++-------- tests/Fixtures/Types/ConstKeyContainer.php | 19 + .../Types/MissingConstKeyContainer.php | 16 + .../ClassConstKeyShapeTest.php | 36 + .../Configuration/DocblockIgnoreTagsTest.php | 13 +- 6 files changed, 504 insertions(+), 328 deletions(-) create mode 100644 tests/Fixtures/Types/ConstKeyContainer.php create mode 100644 tests/Fixtures/Types/MissingConstKeyContainer.php create mode 100644 tests/TypeChecking/ArraysAndShapes/ClassConstKeyShapeTest.php diff --git a/src/Internal/ContractVisitor.php b/src/Internal/ContractVisitor.php index f30cd6e..f80cf8b 100644 --- a/src/Internal/ContractVisitor.php +++ b/src/Internal/ContractVisitor.php @@ -214,4 +214,4 @@ private function extractDestructuringVariables(Node\Expr\List_|Node\Expr\Array_ return $vars; } -} \ No newline at end of file +} diff --git a/src/Resolver/SpecialTypeResolver.php b/src/Resolver/SpecialTypeResolver.php index 4a243f1..8176fa9 100644 --- a/src/Resolver/SpecialTypeResolver.php +++ b/src/Resolver/SpecialTypeResolver.php @@ -68,23 +68,13 @@ public static function checkThisIdentity(TypeNode $returnTypeNode, mixed $value, } /** - * Recursively resolves special type identifiers (self, static, parent, FQCNs, ConstFetch class names) in a TypeNode AST using Reflection context. + * Recursively resolves special type identifiers in a TypeNode AST using Reflection context. * * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod|string $context */ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunction|\ReflectionMethod|string $context, ?object $thisObj = null): TypeNode { - if (\is_string($context)) { - if (str_contains($context, '::')) { - [$className, $methodName] = explode('::', $context, 2); - $ref = new \ReflectionMethod($className, $methodName); - } else { - $ref = new \ReflectionFunction($context); - } - } else { - $ref = $context; - } - + $ref = self::getReflectionContext($context); $declaringClass = $ref instanceof \ReflectionMethod ? $ref->getDeclaringClass()->getName() : ($ref instanceof \ReflectionClass ? $ref->getName() : null); if ($node instanceof ThisTypeNode) { @@ -92,55 +82,16 @@ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunct } if ($node instanceof IdentifierTypeNode) { - $lower = strtolower($node->name); - - if ($lower === '$this' || $lower === 'static') { - return $node; - } - - if ($lower === 'self' && $declaringClass !== null) { - return new IdentifierTypeNode($declaringClass); - } - - if ($lower === 'parent' && $declaringClass !== null) { - $parentClass = get_parent_class($declaringClass); - if ($parentClass !== false) { - return new IdentifierTypeNode($parentClass); - } - } - - $fqcn = self::resolveFqcn($node->name, $ref); - if ($fqcn !== $node->name) { - return new IdentifierTypeNode($fqcn); - } + return self::resolveIdentifier($node, $declaringClass, $ref); } if ($node instanceof ConstTypeNode) { - if ($node->constExpr instanceof ConstFetchNode && $node->constExpr->className !== '') { - $className = $node->constExpr->className; - $lowerClassName = strtolower($className); - - if ($lowerClassName === 'self' && $declaringClass !== null) { - $resolvedClass = $declaringClass; - } elseif ($lowerClassName === 'parent' && $declaringClass !== null) { - $parentClass = get_parent_class($declaringClass); - $resolvedClass = $parentClass !== false ? $parentClass : $className; - } else { - $resolvedClass = self::resolveFqcn($className, $ref); - } - - return new ConstTypeNode(new ConstFetchNode($resolvedClass, $node->constExpr->name)); - } - - return $node; + return self::resolveConstType($node, $declaringClass, $ref); } if ($node instanceof GenericTypeNode) { - $genericType = self::resolve($node->type, $context, $thisObj); - $innerTypes = array_map( - fn ($t) => self::resolve($t, $context, $thisObj), - $node->genericTypes - ); + $genericType = self::resolve($node->type, $ref, $thisObj); + $innerTypes = array_map(fn ($t) => self::resolve($t, $ref, $thisObj), $node->genericTypes); return new GenericTypeNode( $genericType instanceof IdentifierTypeNode ? $genericType : $node->type, @@ -149,164 +100,56 @@ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunct ); } - if ($node instanceof ConditionalTypeNode) { - return new ConditionalTypeNode( - self::resolve($node->subjectType, $context, $thisObj), - self::resolve($node->targetType, $context, $thisObj), - self::resolve($node->if, $context, $thisObj), - self::resolve($node->else, $context, $thisObj), - $node->negated - ); - } - - if ($node instanceof ConditionalTypeForParameterNode) { - return new ConditionalTypeForParameterNode( - $node->parameterName, - self::resolve($node->targetType, $context, $thisObj), - self::resolve($node->if, $context, $thisObj), - self::resolve($node->else, $context, $thisObj), - $node->negated - ); - } - if ($node instanceof OffsetAccessTypeNode) { - $baseType = self::resolve($node->type, $context, $thisObj); - $offsetType = self::resolve($node->offset, $context, $thisObj); - - $offsetKey = null; - if ($offsetType instanceof ConstTypeNode) { - $expr = $offsetType->constExpr; - if ($expr instanceof ConstExprStringNode) { - $offsetKey = $expr->value; - } elseif ($expr instanceof ConstExprIntegerNode) { - $offsetKey = (int) $expr->value; - } - } elseif ($offsetType instanceof IdentifierTypeNode) { - $offsetKey = $offsetType->name; - } - - if ($offsetKey !== null) { - if ($baseType instanceof ArrayShapeNode) { - foreach ($baseType->items as $item) { - $itemKey = null; - if ($item->keyName instanceof ConstExprStringNode) { - $itemKey = $item->keyName->value; - } elseif ($item->keyName instanceof IdentifierTypeNode) { - $itemKey = $item->keyName->name; - } elseif ($item->keyName instanceof ConstExprIntegerNode) { - $itemKey = (int) $item->keyName->value; - } - - if ((string) $itemKey === (string) $offsetKey) { - return $item->valueType; - } - } - } - - if ($baseType instanceof ConstTypeNode && $baseType->constExpr instanceof ConstFetchNode) { - $constExpr = $baseType->constExpr; - $fqcn = $constExpr->className; - $constName = $constExpr->name; - - if ($fqcn !== '' && (class_exists($fqcn) || interface_exists($fqcn))) { - try { - $refClass = new \ReflectionClass($fqcn); - if ($refClass->hasConstant($constName)) { - $constValue = $refClass->getConstant($constName); - if (\is_array($constValue) && \array_key_exists($offsetKey, $constValue)) { - $val = $constValue[$offsetKey]; - if (\is_string($val)) { - return new ConstTypeNode(new ConstExprStringNode($val, ConstExprStringNode::SINGLE_QUOTED)); - } elseif (\is_int($val)) { - return new ConstTypeNode(new ConstExprIntegerNode((string) $val)); - } - } - } - } catch (\ReflectionException $e) { - } - } - } - } - - return new OffsetAccessTypeNode($baseType, $offsetType); + return self::resolveOffsetAccess($node, $ref, $thisObj); } if ($node instanceof ArrayShapeNode) { - $items = array_map(function ($item) use ($context, $thisObj) { - return new ArrayShapeItemNode( - $item->keyName, - $item->optional, - self::resolve($item->valueType, $context, $thisObj) - ); - }, $node->items); - - if ($node->sealed) { - return ArrayShapeNode::createSealed($items, $node->kind); - } else { - $unsealedType = null; - if ($node->unsealedType !== null) { - $unsealedKey = $node->unsealedType->keyType !== null ? self::resolve($node->unsealedType->keyType, $context, $thisObj) : null; - $unsealedValue = self::resolve($node->unsealedType->valueType, $context, $thisObj); - $unsealedType = new ArrayShapeUnsealedTypeNode($unsealedValue, $unsealedKey); - } - - return ArrayShapeNode::createUnsealed($items, $unsealedType, $node->kind); - } + return self::resolveArrayShape($node, $ref, $thisObj); } if ($node instanceof ObjectShapeNode) { - $items = array_map(function ($item) use ($context, $thisObj) { - return new ObjectShapeItemNode( - $item->keyName, - $item->optional, - self::resolve($item->valueType, $context, $thisObj) - ); - }, $node->items); - - return new ObjectShapeNode($items); + return self::resolveObjectShape($node, $ref, $thisObj); } if ($node instanceof CallableTypeNode) { - $resolvedParameters = array_map(function (CallableTypeParameterNode $param) use ($context, $thisObj) { - return new CallableTypeParameterNode( - self::resolve($param->type, $context, $thisObj), - $param->isReference, - $param->isVariadic, - $param->parameterName, - $param->isOptional - ); - }, $node->parameters); - - $resolvedReturnType = self::resolve($node->returnType, $context, $thisObj); - - return new CallableTypeNode( - $node->identifier, - $resolvedParameters, - $resolvedReturnType, - $node->templateTypes + return self::resolveCallable($node, $ref, $thisObj); + } + + if ($node instanceof ConditionalTypeNode) { + return new ConditionalTypeNode( + self::resolve($node->subjectType, $ref, $thisObj), + self::resolve($node->targetType, $ref, $thisObj), + self::resolve($node->if, $ref, $thisObj), + self::resolve($node->else, $ref, $thisObj), + $node->negated + ); + } + + if ($node instanceof ConditionalTypeForParameterNode) { + return new ConditionalTypeForParameterNode( + $node->parameterName, + self::resolve($node->targetType, $ref, $thisObj), + self::resolve($node->if, $ref, $thisObj), + self::resolve($node->else, $ref, $thisObj), + $node->negated ); } if ($node instanceof NullableTypeNode) { - return new NullableTypeNode(self::resolve($node->type, $context, $thisObj)); + return new NullableTypeNode(self::resolve($node->type, $ref, $thisObj)); } if ($node instanceof ArrayTypeNode) { - return new ArrayTypeNode(self::resolve($node->type, $context, $thisObj)); + return new ArrayTypeNode(self::resolve($node->type, $ref, $thisObj)); } if ($node instanceof UnionTypeNode) { - return new UnionTypeNode(array_map( - fn ($t) => self::resolve($t, $context, $thisObj), - $node->types - )); + return new UnionTypeNode(array_map(fn ($t) => self::resolve($t, $ref, $thisObj), $node->types)); } if ($node instanceof IntersectionTypeNode) { - return new IntersectionTypeNode(array_map( - fn ($t) => self::resolve($t, $context, $thisObj), - $node->types - )); + return new IntersectionTypeNode(array_map(fn ($t) => self::resolve($t, $ref, $thisObj), $node->types)); } return $node; @@ -328,34 +171,17 @@ public static function resolveForFile(TypeNode $node, string $file): TypeNode } $fqcn = self::resolveFqcnForFile($node->name, $file); - if ($fqcn !== $node->name) { - return new IdentifierTypeNode($fqcn); - } + + return $fqcn !== $node->name ? new IdentifierTypeNode($fqcn) : clone $node; } if ($node instanceof ConstTypeNode) { - if ($node->constExpr instanceof ConstFetchNode && $node->constExpr->className !== '') { - $className = $node->constExpr->className; - $lowerClassName = strtolower($className); - - if ($lowerClassName === 'self' || $lowerClassName === 'parent') { - $resolvedClass = $className; - } else { - $resolvedClass = self::resolveFqcnForFile($className, $file); - } - - return new ConstTypeNode(new ConstFetchNode($resolvedClass, $node->constExpr->name)); - } - - return clone $node; + return self::resolveConstTypeForFile($node, $file); } if ($node instanceof GenericTypeNode) { $genericType = self::resolveForFile($node->type, $file); - $innerTypes = array_map( - fn ($t) => self::resolveForFile($t, $file), - $node->genericTypes - ); + $innerTypes = array_map(fn ($t) => self::resolveForFile($t, $file), $node->genericTypes); return new GenericTypeNode( $genericType instanceof IdentifierTypeNode ? $genericType : $node->type, @@ -364,6 +190,22 @@ public static function resolveForFile(TypeNode $node, string $file): TypeNode ); } + if ($node instanceof OffsetAccessTypeNode) { + return self::resolveOffsetAccessForFile($node, $file); + } + + if ($node instanceof ArrayShapeNode) { + return self::resolveArrayShapeForFile($node, $file); + } + + if ($node instanceof ObjectShapeNode) { + return self::resolveObjectShapeForFile($node, $file); + } + + if ($node instanceof CallableTypeNode) { + return self::resolveCallableForFile($node, $file); + } + if ($node instanceof ConditionalTypeNode) { return new ConditionalTypeNode( self::resolveForFile($node->subjectType, $file), @@ -384,147 +226,407 @@ public static function resolveForFile(TypeNode $node, string $file): TypeNode ); } - if ($node instanceof OffsetAccessTypeNode) { - $baseType = self::resolveForFile($node->type, $file); - $offsetType = self::resolveForFile($node->offset, $file); - - $offsetKey = null; - if ($offsetType instanceof ConstTypeNode) { - $expr = $offsetType->constExpr; - if ($expr instanceof ConstExprStringNode) { - $offsetKey = $expr->value; - } elseif ($expr instanceof ConstExprIntegerNode) { - $offsetKey = (int) $expr->value; - } - } elseif ($offsetType instanceof IdentifierTypeNode) { - $offsetKey = $offsetType->name; + if ($node instanceof NullableTypeNode) { + return new NullableTypeNode(self::resolveForFile($node->type, $file)); + } + + if ($node instanceof ArrayTypeNode) { + return new ArrayTypeNode(self::resolveForFile($node->type, $file)); + } + + if ($node instanceof UnionTypeNode) { + return new UnionTypeNode(array_map(fn ($t) => self::resolveForFile($t, $file), $node->types)); + } + + if ($node instanceof IntersectionTypeNode) { + return new IntersectionTypeNode(array_map(fn ($t) => self::resolveForFile($t, $file), $node->types)); + } + + return clone $node; + } + + /** + * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod|string $context + * + * @return \ReflectionClass|\ReflectionFunction|\ReflectionMethod + */ + private static function getReflectionContext(\ReflectionClass|\ReflectionFunction|\ReflectionMethod|string $context): \ReflectionClass|\ReflectionFunction|\ReflectionMethod + { + if (\is_string($context)) { + if (str_contains($context, '::')) { + [$className, $methodName] = explode('::', $context, 2); + + return new \ReflectionMethod($className, $methodName); } - if ($offsetKey !== null) { - if ($baseType instanceof ArrayShapeNode) { - foreach ($baseType->items as $item) { - $itemKey = null; - if ($item->keyName instanceof ConstExprStringNode) { - $itemKey = $item->keyName->value; - } elseif ($item->keyName instanceof IdentifierTypeNode) { - $itemKey = $item->keyName->name; - } elseif ($item->keyName instanceof ConstExprIntegerNode) { - $itemKey = (int) $item->keyName->value; - } + return new \ReflectionFunction($context); + } - if ((string) $itemKey === (string) $offsetKey) { - return $item->valueType; - } + return $context; + } + + /** + * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref + */ + private static function resolveIdentifier(IdentifierTypeNode $node, ?string $declaringClass, \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref): IdentifierTypeNode + { + $lower = strtolower($node->name); + + if ($lower === '$this' || $lower === 'static') { + return $node; + } + + if ($lower === 'self' && $declaringClass !== null) { + return new IdentifierTypeNode($declaringClass); + } + + if ($lower === 'parent' && $declaringClass !== null) { + $parentClass = get_parent_class($declaringClass); + if ($parentClass !== false) { + return new IdentifierTypeNode($parentClass); + } + } + + $fqcn = self::resolveFqcn($node->name, $ref); + + return $fqcn !== $node->name ? new IdentifierTypeNode($fqcn) : $node; + } + + /** + * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref + */ + private static function resolveConstType(ConstTypeNode $node, ?string $declaringClass, \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref): ConstTypeNode + { + if ($node->constExpr instanceof ConstFetchNode && $node->constExpr->className !== '') { + $className = $node->constExpr->className; + $lowerClassName = strtolower($className); + + if ($lowerClassName === 'self' && $declaringClass !== null) { + $resolvedClass = $declaringClass; + } elseif ($lowerClassName === 'parent' && $declaringClass !== null) { + $parentClass = get_parent_class($declaringClass); + $resolvedClass = $parentClass !== false ? $parentClass : $className; + } else { + $resolvedClass = self::resolveFqcn($className, $ref); + } + + return new ConstTypeNode(new ConstFetchNode($resolvedClass, $node->constExpr->name)); + } + + return $node; + } + + /** + * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref + */ + private static function resolveOffsetAccess(OffsetAccessTypeNode $node, \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref, ?object $thisObj): TypeNode + { + $baseType = self::resolve($node->type, $ref, $thisObj); + $offsetType = self::resolve($node->offset, $ref, $thisObj); + + $offsetKey = self::extractOffsetKey($offsetType); + + if ($offsetKey !== null) { + if ($baseType instanceof ArrayShapeNode) { + foreach ($baseType->items as $item) { + $itemKey = self::extractItemKey($item->keyName); + if ((string) $itemKey === (string) $offsetKey) { + return $item->valueType; } } + } - if ($baseType instanceof ConstTypeNode && $baseType->constExpr instanceof ConstFetchNode) { - $constExpr = $baseType->constExpr; - $fqcn = $constExpr->className; - $constName = $constExpr->name; - - if ($fqcn !== '' && (class_exists($fqcn) || interface_exists($fqcn))) { - try { - $refClass = new \ReflectionClass($fqcn); - if ($refClass->hasConstant($constName)) { - $constValue = $refClass->getConstant($constName); - if (\is_array($constValue) && \array_key_exists($offsetKey, $constValue)) { - $val = $constValue[$offsetKey]; - if (\is_string($val)) { - return new ConstTypeNode(new ConstExprStringNode($val, ConstExprStringNode::SINGLE_QUOTED)); - } elseif (\is_int($val)) { - return new ConstTypeNode(new ConstExprIntegerNode((string) $val)); - } - } - } - } catch (\ReflectionException $e) { - } - } + if ($baseType instanceof ConstTypeNode && $baseType->constExpr instanceof ConstFetchNode) { + $resolvedNode = self::resolveConstantOffsetValue($baseType->constExpr->className, $baseType->constExpr->name, $offsetKey); + if ($resolvedNode !== null) { + return $resolvedNode; } } + } + + return new OffsetAccessTypeNode($baseType, $offsetType); + } - return new OffsetAccessTypeNode($baseType, $offsetType); + /** + * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref + */ + private static function resolveArrayShape(ArrayShapeNode $node, \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref, ?object $thisObj): ArrayShapeNode + { + $items = array_map(function ($item) use ($ref, $thisObj) { + /** @var ConstExprIntegerNode|ConstExprStringNode|ConstFetchNode|IdentifierTypeNode|null $keyName */ + $keyName = $item->keyName; + + if ($keyName instanceof ConstFetchNode && $keyName->className !== '') { + $className = $keyName->className; + $lowerClassName = strtolower($className); + $declaringClass = $ref instanceof \ReflectionMethod ? $ref->getDeclaringClass()->getName() : null; + + if ($lowerClassName === 'self' && $declaringClass !== null) { + $resolvedClass = $declaringClass; + } elseif ($lowerClassName === 'parent' && $declaringClass !== null) { + $parentClass = get_parent_class($declaringClass); + $resolvedClass = $parentClass !== false ? $parentClass : $className; + } else { + $resolvedClass = self::resolveFqcn($className, $ref); + } + + $resolvedKeyNode = self::resolveConstantKeyValue($resolvedClass, $keyName->name); + if ($resolvedKeyNode !== null) { + $keyName = $resolvedKeyNode; + } + } + + return new ArrayShapeItemNode( + $keyName, + $item->optional, + self::resolve($item->valueType, $ref, $thisObj) + ); + }, $node->items); + + if ($node->sealed) { + return ArrayShapeNode::createSealed($items, $node->kind); } - if ($node instanceof ArrayShapeNode) { - $items = array_map(function ($item) use ($file) { - return new ArrayShapeItemNode( - $item->keyName, - $item->optional, - self::resolveForFile($item->valueType, $file) - ); - }, $node->items); - - if ($node->sealed) { - return ArrayShapeNode::createSealed($items, $node->kind); + $unsealedType = null; + if ($node->unsealedType !== null) { + $unsealedKey = $node->unsealedType->keyType !== null ? self::resolve($node->unsealedType->keyType, $ref, $thisObj) : null; + $unsealedValue = self::resolve($node->unsealedType->valueType, $ref, $thisObj); + $unsealedType = new ArrayShapeUnsealedTypeNode($unsealedValue, $unsealedKey); + } + + return ArrayShapeNode::createUnsealed($items, $unsealedType, $node->kind); + } + + /** + * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref + */ + private static function resolveObjectShape(ObjectShapeNode $node, \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref, ?object $thisObj): ObjectShapeNode + { + $items = array_map(function ($item) use ($ref, $thisObj) { + return new ObjectShapeItemNode( + $item->keyName, + $item->optional, + self::resolve($item->valueType, $ref, $thisObj) + ); + }, $node->items); + + return new ObjectShapeNode($items); + } + + /** + * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref + */ + private static function resolveCallable(CallableTypeNode $node, \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref, ?object $thisObj): CallableTypeNode + { + $resolvedParameters = array_map(function (CallableTypeParameterNode $param) use ($ref, $thisObj) { + return new CallableTypeParameterNode( + self::resolve($param->type, $ref, $thisObj), + $param->isReference, + $param->isVariadic, + $param->parameterName, + $param->isOptional + ); + }, $node->parameters); + + $resolvedReturnType = self::resolve($node->returnType, $ref, $thisObj); + + return new CallableTypeNode($node->identifier, $resolvedParameters, $resolvedReturnType, $node->templateTypes); + } + + private static function resolveConstTypeForFile(ConstTypeNode $node, string $file): ConstTypeNode + { + if ($node->constExpr instanceof ConstFetchNode && $node->constExpr->className !== '') { + $className = $node->constExpr->className; + $lowerClassName = strtolower($className); + + if ($lowerClassName === 'self' || $lowerClassName === 'parent') { + $resolvedClass = $className; } else { - $unsealedType = null; - if ($node->unsealedType !== null) { - $unsealedKey = $node->unsealedType->keyType !== null ? self::resolveForFile($node->unsealedType->keyType, $file) : null; - $unsealedValue = self::resolveForFile($node->unsealedType->valueType, $file); - $unsealedType = new ArrayShapeUnsealedTypeNode($unsealedValue, $unsealedKey); + $resolvedClass = self::resolveFqcnForFile($className, $file); + } + + return new ConstTypeNode(new ConstFetchNode($resolvedClass, $node->constExpr->name)); + } + + return clone $node; + } + + private static function resolveOffsetAccessForFile(OffsetAccessTypeNode $node, string $file): TypeNode + { + $baseType = self::resolveForFile($node->type, $file); + $offsetType = self::resolveForFile($node->offset, $file); + + $offsetKey = self::extractOffsetKey($offsetType); + + if ($offsetKey !== null) { + if ($baseType instanceof ArrayShapeNode) { + foreach ($baseType->items as $item) { + $itemKey = self::extractItemKey($item->keyName); + if ((string) $itemKey === (string) $offsetKey) { + return $item->valueType; + } } + } - return ArrayShapeNode::createUnsealed($items, $unsealedType, $node->kind); + if ($baseType instanceof ConstTypeNode && $baseType->constExpr instanceof ConstFetchNode) { + $resolvedNode = self::resolveConstantOffsetValue($baseType->constExpr->className, $baseType->constExpr->name, $offsetKey); + if ($resolvedNode !== null) { + return $resolvedNode; + } } } - if ($node instanceof ObjectShapeNode) { - $items = array_map(function ($item) use ($file) { - return new ObjectShapeItemNode( - $item->keyName, - $item->optional, - self::resolveForFile($item->valueType, $file) - ); - }, $node->items); + return new OffsetAccessTypeNode($baseType, $offsetType); + } - return new ObjectShapeNode($items); - } + private static function resolveArrayShapeForFile(ArrayShapeNode $node, string $file): ArrayShapeNode + { + $items = array_map(function ($item) use ($file) { + /** @var ConstExprIntegerNode|ConstExprStringNode|ConstFetchNode|IdentifierTypeNode|null $keyName */ + $keyName = $item->keyName; - if ($node instanceof CallableTypeNode) { - $resolvedParameters = array_map(function (CallableTypeParameterNode $param) use ($file) { - return new CallableTypeParameterNode( - self::resolveForFile($param->type, $file), - $param->isReference, - $param->isVariadic, - $param->parameterName, - $param->isOptional - ); - }, $node->parameters); - - $resolvedReturnType = self::resolveForFile($node->returnType, $file); - - return new CallableTypeNode( - $node->identifier, - $resolvedParameters, - $resolvedReturnType, - $node->templateTypes + if ($keyName instanceof ConstFetchNode && $keyName->className !== '') { + $className = $keyName->className; + $lowerClassName = strtolower($className); + + if ($lowerClassName !== 'self' && $lowerClassName !== 'parent') { + $resolvedClass = self::resolveFqcnForFile($className, $file); + $resolvedKeyNode = self::resolveConstantKeyValue($resolvedClass, $keyName->name); + if ($resolvedKeyNode !== null) { + $keyName = $resolvedKeyNode; + } + } + } + + return new ArrayShapeItemNode( + $keyName, + $item->optional, + self::resolveForFile($item->valueType, $file) ); + }, $node->items); + + if ($node->sealed) { + return ArrayShapeNode::createSealed($items, $node->kind); } - if ($node instanceof NullableTypeNode) { - return new NullableTypeNode(self::resolveForFile($node->type, $file)); + $unsealedType = null; + if ($node->unsealedType !== null) { + $unsealedKey = $node->unsealedType->keyType !== null ? self::resolveForFile($node->unsealedType->keyType, $file) : null; + $unsealedValue = self::resolveForFile($node->unsealedType->valueType, $file); + $unsealedType = new ArrayShapeUnsealedTypeNode($unsealedValue, $unsealedKey); } - if ($node instanceof ArrayTypeNode) { - return new ArrayTypeNode(self::resolveForFile($node->type, $file)); + return ArrayShapeNode::createUnsealed($items, $unsealedType, $node->kind); + } + + private static function resolveObjectShapeForFile(ObjectShapeNode $node, string $file): ObjectShapeNode + { + $items = array_map(function ($item) use ($file) { + return new ObjectShapeItemNode( + $item->keyName, + $item->optional, + self::resolveForFile($item->valueType, $file) + ); + }, $node->items); + + return new ObjectShapeNode($items); + } + + private static function resolveCallableForFile(CallableTypeNode $node, string $file): CallableTypeNode + { + $resolvedParameters = array_map(function (CallableTypeParameterNode $param) use ($file) { + return new CallableTypeParameterNode( + self::resolveForFile($param->type, $file), + $param->isReference, + $param->isVariadic, + $param->parameterName, + $param->isOptional + ); + }, $node->parameters); + + $resolvedReturnType = self::resolveForFile($node->returnType, $file); + + return new CallableTypeNode($node->identifier, $resolvedParameters, $resolvedReturnType, $node->templateTypes); + } + + // --- Shared Utilities --- + + private static function extractOffsetKey(TypeNode $offsetType): string|int|null + { + if ($offsetType instanceof ConstTypeNode) { + $expr = $offsetType->constExpr; + if ($expr instanceof ConstExprStringNode) { + return $expr->value; + } + if ($expr instanceof ConstExprIntegerNode) { + return (int) $expr->value; + } + } + if ($offsetType instanceof IdentifierTypeNode) { + return $offsetType->name; } - if ($node instanceof UnionTypeNode) { - return new UnionTypeNode(array_map( - fn ($t) => self::resolveForFile($t, $file), - $node->types - )); + return null; + } + + private static function extractItemKey(mixed $keyName): string|int|null + { + if ($keyName instanceof ConstExprStringNode) { + return $keyName->value; + } + if ($keyName instanceof IdentifierTypeNode) { + return $keyName->name; + } + if ($keyName instanceof ConstExprIntegerNode) { + return (int) $keyName->value; } - if ($node instanceof IntersectionTypeNode) { - return new IntersectionTypeNode(array_map( - fn ($t) => self::resolveForFile($t, $file), - $node->types - )); + return null; + } + + private static function resolveConstantOffsetValue(string $fqcn, string $constName, string|int $offsetKey): ?TypeNode + { + if ($fqcn !== '' && (class_exists($fqcn) || interface_exists($fqcn))) { + try { + $refClass = new \ReflectionClass($fqcn); + if ($refClass->hasConstant($constName)) { + $constValue = $refClass->getConstant($constName); + if (\is_array($constValue) && \array_key_exists($offsetKey, $constValue)) { + $val = $constValue[$offsetKey]; + if (\is_string($val)) { + return new ConstTypeNode(new ConstExprStringNode($val, ConstExprStringNode::SINGLE_QUOTED)); + } + if (\is_int($val)) { + return new ConstTypeNode(new ConstExprIntegerNode((string) $val)); + } + } + } + } catch (\ReflectionException $e) { + } } - return clone $node; + return null; + } + + private static function resolveConstantKeyValue(string $fqcn, string $constName): ConstExprStringNode|ConstExprIntegerNode|null + { + if (class_exists($fqcn) || interface_exists($fqcn)) { + try { + $refClass = new \ReflectionClass($fqcn); + if ($refClass->hasConstant($constName)) { + $val = $refClass->getConstant($constName); + if (\is_string($val)) { + return new ConstExprStringNode($val, ConstExprStringNode::SINGLE_QUOTED); + } + if (\is_int($val)) { + return new ConstExprIntegerNode((string) $val); + } + } + } catch (\ReflectionException $e) { + } + } + + return null; } /** diff --git a/tests/Fixtures/Types/ConstKeyContainer.php b/tests/Fixtures/Types/ConstKeyContainer.php new file mode 100644 index 0000000..3c56895 --- /dev/null +++ b/tests/Fixtures/Types/ConstKeyContainer.php @@ -0,0 +1,19 @@ +process([ + 'user_id' => 42, + 'user_role' => 'admin', + ]))->toBeTrue(); + }); + + test('throws TypeError when array shape item with class constant key violates type contract', function () { + $container = new ConstKeyContainer(); + + expect(fn () => $container->process([ + 'user_id' => -5, + 'user_role' => 'admin', + ]))->toThrow(TypeError::class, "['user_id'] must be of type positive-int"); + }); + + test('throws TypeError when array shape references a non-existent class constant key', function () { + $container = new MissingConstKeyContainer(); + + expect(fn () => $container->process(['user_id' => 42])) + ->toThrow(TypeError::class, "is missing required key 'self::NON_EXISTENT_KEY'") + ; + }); + +}); diff --git a/tests/TypeChecking/Configuration/DocblockIgnoreTagsTest.php b/tests/TypeChecking/Configuration/DocblockIgnoreTagsTest.php index 1b5b930..d636966 100644 --- a/tests/TypeChecking/Configuration/DocblockIgnoreTagsTest.php +++ b/tests/TypeChecking/Configuration/DocblockIgnoreTagsTest.php @@ -28,8 +28,9 @@ function testIgnoredFunction(int $id): int test('skips type-checking on method marked with @typephp-ignore while enforcing normal methods in same class', function () { $fixture = new IgnoredMethod(); - expect(fn() => $fixture->normalMethod(-5)) - ->toThrow(TypeError::class, 'positive-int'); + expect(fn () => $fixture->normalMethod(-5)) + ->toThrow(TypeError::class, 'positive-int') + ; expect($fixture->ignoredMethod(-100))->toBe(-100); }); @@ -37,8 +38,9 @@ function testIgnoredFunction(int $id): int test('skips type-checking on class property marked with @typephp-ignore', function () { $fixture = new IgnoredMethod(); - expect(fn() => $fixture->setNormalProperty(-5)) - ->toThrow(TypeError::class, 'Property'); + expect(fn () => $fixture->setNormalProperty(-5)) + ->toThrow(TypeError::class, 'Property') + ; $fixture->setIgnoredProperty(-5); expect($fixture->ignoredProperty)->toBe(-5); @@ -52,9 +54,10 @@ function testIgnoredFunction(int $id): int }); test('skips inline variable validation inside methods marked with @typephp-ignore', function () { - $fixture = new class() { + $fixture = new class () { /** * @typephp-ignore + * * @param positive-int $id */ public function ignoredMethodWithInlineVar(int $id): bool From 217b4cfdbb9689f5dd2c451420a1dd861785f55c Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 11 Aug 2026 15:38:53 +0800 Subject: [PATCH 7/9] Add ClassStringFactoryContainer and corresponding tests for class-string validation --- .../Types/ClassStringFactoryContainer.php | 18 ++++++++++++ .../Scalars/ClassStringInterfaceTest.php | 28 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 tests/Fixtures/Types/ClassStringFactoryContainer.php create mode 100644 tests/TypeChecking/Scalars/ClassStringInterfaceTest.php diff --git a/tests/Fixtures/Types/ClassStringFactoryContainer.php b/tests/Fixtures/Types/ClassStringFactoryContainer.php new file mode 100644 index 0000000..6bd01c7 --- /dev/null +++ b/tests/Fixtures/Types/ClassStringFactoryContainer.php @@ -0,0 +1,18 @@ + $class + */ + public static function makeCountable(string $class): string + { + return $class; + } +} diff --git a/tests/TypeChecking/Scalars/ClassStringInterfaceTest.php b/tests/TypeChecking/Scalars/ClassStringInterfaceTest.php new file mode 100644 index 0000000..a41aca8 --- /dev/null +++ b/tests/TypeChecking/Scalars/ClassStringInterfaceTest.php @@ -0,0 +1,28 @@ + Subtype Validation', function () { + + test('accepts concrete class string implementing the target interface', function () { + expect(ClassStringFactoryContainer::makeCountable(CountableArrayAccess::class)) + ->toBe(CountableArrayAccess::class) + ; + }); + + test('accepts the interface string itself', function () { + expect(ClassStringFactoryContainer::makeCountable(Countable::class)) + ->toBe(Countable::class) + ; + }); + + test('throws TypeError when class string does not implement the target interface', function () { + expect(fn () => ClassStringFactoryContainer::makeCountable(Car::class)) + ->toThrow(TypeError::class, 'must be a class-string of Countable') + ; + }); +}); From 437d369d928089b01efd39977ffad5260070a0a9 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 11 Aug 2026 16:05:25 +0800 Subject: [PATCH 8/9] Refactor SpecialTypeResolver to extract class and constant names for improved key resolution in reflection contexts to support 8.2 below --- src/Resolver/SpecialTypeResolver.php | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/Resolver/SpecialTypeResolver.php b/src/Resolver/SpecialTypeResolver.php index 8176fa9..6e76c76 100644 --- a/src/Resolver/SpecialTypeResolver.php +++ b/src/Resolver/SpecialTypeResolver.php @@ -245,6 +245,8 @@ public static function resolveForFile(TypeNode $node, string $file): TypeNode return clone $node; } + // --- Private Helper Extractions for Reflection Context --- + /** * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod|string $context * @@ -356,8 +358,17 @@ private static function resolveArrayShape(ArrayShapeNode $node, \ReflectionClass /** @var ConstExprIntegerNode|ConstExprStringNode|ConstFetchNode|IdentifierTypeNode|null $keyName */ $keyName = $item->keyName; + $className = null; + $constName = null; + if ($keyName instanceof ConstFetchNode && $keyName->className !== '') { $className = $keyName->className; + $constName = $keyName->name; + } elseif ($keyName instanceof IdentifierTypeNode && str_contains($keyName->name, '::')) { + [$className, $constName] = explode('::', $keyName->name, 2); + } + + if ($className !== null && $constName !== null) { $lowerClassName = strtolower($className); $declaringClass = $ref instanceof \ReflectionMethod ? $ref->getDeclaringClass()->getName() : null; @@ -370,7 +381,7 @@ private static function resolveArrayShape(ArrayShapeNode $node, \ReflectionClass $resolvedClass = self::resolveFqcn($className, $ref); } - $resolvedKeyNode = self::resolveConstantKeyValue($resolvedClass, $keyName->name); + $resolvedKeyNode = self::resolveConstantKeyValue($resolvedClass, $constName); if ($resolvedKeyNode !== null) { $keyName = $resolvedKeyNode; } @@ -433,6 +444,8 @@ private static function resolveCallable(CallableTypeNode $node, \ReflectionClass return new CallableTypeNode($node->identifier, $resolvedParameters, $resolvedReturnType, $node->templateTypes); } + // --- Private Helper Extractions for File Context --- + private static function resolveConstTypeForFile(ConstTypeNode $node, string $file): ConstTypeNode { if ($node->constExpr instanceof ConstFetchNode && $node->constExpr->className !== '') { @@ -485,13 +498,22 @@ private static function resolveArrayShapeForFile(ArrayShapeNode $node, string $f /** @var ConstExprIntegerNode|ConstExprStringNode|ConstFetchNode|IdentifierTypeNode|null $keyName */ $keyName = $item->keyName; + $className = null; + $constName = null; + if ($keyName instanceof ConstFetchNode && $keyName->className !== '') { $className = $keyName->className; + $constName = $keyName->name; + } elseif ($keyName instanceof IdentifierTypeNode && str_contains($keyName->name, '::')) { + [$className, $constName] = explode('::', $keyName->name, 2); + } + + if ($className !== null && $constName !== null) { $lowerClassName = strtolower($className); if ($lowerClassName !== 'self' && $lowerClassName !== 'parent') { $resolvedClass = self::resolveFqcnForFile($className, $file); - $resolvedKeyNode = self::resolveConstantKeyValue($resolvedClass, $keyName->name); + $resolvedKeyNode = self::resolveConstantKeyValue($resolvedClass, $constName); if ($resolvedKeyNode !== null) { $keyName = $resolvedKeyNode; } From 2a725041fbbdace5cf3fcc46e3701aaf1f3d204e Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Tue, 11 Aug 2026 16:35:34 +0800 Subject: [PATCH 9/9] Improve property hook checks and enhance node handling in PropertyHookInjector --- src/Internal/Visitor/PropertyHookInjector.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Internal/Visitor/PropertyHookInjector.php b/src/Internal/Visitor/PropertyHookInjector.php index 65e26d3..8afc951 100644 --- a/src/Internal/Visitor/PropertyHookInjector.php +++ b/src/Internal/Visitor/PropertyHookInjector.php @@ -16,7 +16,7 @@ final class PropertyHookInjector { public static function process(Node\Stmt\Property $node): void { - if ($node->hooks === []) { + if (! isset($node->hooks) || ! \is_array($node->hooks) || $node->hooks === []) { return; } @@ -76,15 +76,15 @@ public function __construct(private string $propertyName) { } - public function enterNode(Node $n): int|null + public function enterNode(Node $node): int|null { - if ($n instanceof Node\Expr\Closure || $n instanceof Node\Expr\ArrowFunction || $n instanceof Node\Stmt\Function_ || $n instanceof Node\Stmt\ClassMethod) { + if ($node instanceof Node\Expr\Closure || $node instanceof Node\Expr\ArrowFunction || $node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod) { return NodeTraverser::DONT_TRAVERSE_CHILDREN; } - if ($n instanceof Node\Stmt\Return_ && $n->expr !== null) { - $checkCall = NodeBuilder::createPropertyCheckCall($n->expr, new Node\Expr\Variable('this'), $this->propertyName); - $n->expr = NodeBuilder::createTernaryThrowExpr($checkCall); + if ($node instanceof Node\Stmt\Return_ && $node->expr !== null) { + $checkCall = NodeBuilder::createPropertyCheckCall($node->expr, new Node\Expr\Variable('this'), $this->propertyName); + $node->expr = NodeBuilder::createTernaryThrowExpr($checkCall); } return null; @@ -96,4 +96,4 @@ public function enterNode(Node $n): int|null return $newStmts; } -} +} \ No newline at end of file