From dadca044afa3c5ce69a2ff0a26e00dea96b9573e Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Thu, 6 Aug 2026 18:10:20 +0800 Subject: [PATCH 01/15] Add initial documentation files and configuration setup for TypePHP --- docs/config.mts | 67 +++++++++++ docs/getting-started/configuration.md | 114 ++++++++++++++++++ docs/getting-started/installation.md | 75 ++++++++++++ docs/getting-started/quick-start.md | 96 ++++++++++++++++ docs/index.md | 49 ++++++++ src/Command/CommandRunner.php | 19 +-- src/Command/ConfigCommand.php | 159 ++++++++++++++++++++++++++ src/Command/HelpCommand.php | 23 ++-- 8 files changed, 581 insertions(+), 21 deletions(-) create mode 100644 docs/config.mts create mode 100644 docs/getting-started/configuration.md create mode 100644 docs/getting-started/installation.md create mode 100644 docs/getting-started/quick-start.md create mode 100644 docs/index.md create mode 100644 src/Command/ConfigCommand.php diff --git a/docs/config.mts b/docs/config.mts new file mode 100644 index 0000000..cda386a --- /dev/null +++ b/docs/config.mts @@ -0,0 +1,67 @@ +import { defineConfig } from 'vitepress' + +export default defineConfig({ + title: "TypePHP", + description: "Zero-cost, production-ready runtime type enforcer for PHP.", + themeConfig: { + nav: [ + { text: 'Home', link: '/' }, + { text: 'Documentation', link: '/getting-started/installation' }, + { text: 'CLI', link: '/production/cache-commands' }, + { text: 'GitHub', link: 'https://github.com/typephp/typephp' } + ], + sidebar: [ + { + text: 'Getting Started', + items: [ + { text: 'Installation', link: '/getting-started/installation' }, + { text: 'Quick Start', link: '/getting-started/quick-start' }, + { text: 'Configuration', link: '/getting-started/configuration' }, + ] + }, + { + text: 'Core Concepts', + items: [ + { text: 'Function Contracts', link: '/core-concepts/function-contracts' }, + { text: 'Inline Variables', link: '/core-concepts/inline-variables' }, + { text: 'Property Validation', link: '/core-concepts/property-validation' }, + { text: 'Generics & Bounds', link: '/core-concepts/generics-and-bounds' }, + { text: 'Type Aliases', link: '/core-concepts/type-aliases' }, + ] + }, + { + text: 'Supported Types', + items: [ + { text: 'Primitives & Scalars', link: '/supported-types/primitives-and-scalars' }, + { text: 'Arrays & Shapes', link: '/supported-types/arrays-and-shapes' }, + { text: 'Callables & Closures', link: '/supported-types/callables-and-closures' }, + { text: 'Iterators & Generators', link: '/supported-types/iterators-and-generators' }, + ] + }, + { + text: 'Advanced Features', + items: [ + { text: 'Liskov & Inheritance', link: '/advanced/liskov-and-inheritance' }, + { text: 'Vendor Isolation', link: '/advanced/vendor-and-path-filtering' }, + { text: 'Ignore Annotations', link: '/advanced/ignore-annotations' }, + { text: 'Extensions', link: '/advanced/extensions' }, + { text: 'Exception Handling', link: '/advanced/exception-handling' }, + ] + }, + { + text: 'Production & Performance', + items: [ + { text: 'Production Readiness', link: '/production/production-readiness' }, + { text: 'Cache CLI Commands', link: '/production/cache-commands' }, + { text: 'Performance Benchmarks', link: '/production/performance-benchmarks' }, + ] + } + ], + socialLinks: [ + { icon: 'github', link: 'https://github.com/typephp/typephp' } + ], + search: { + provider: 'local' + } + } +}) \ No newline at end of file diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md new file mode 100644 index 0000000..47e55f2 --- /dev/null +++ b/docs/getting-started/configuration.md @@ -0,0 +1,114 @@ +# Configuration + +Generate a default `typephp.php` configuration file in your project root directory: + +```bash +vendor/bin/typephp config:init +``` + +--- + +## Default Configuration Options + +```php + true, + + /* + |-------------------------------------------------------------------------- + | Function Boundary Contracts (@param & @return) + |-------------------------------------------------------------------------- + | Enforces function and method parameter and return type contracts uniformly. + */ + 'params' => true, + 'returns' => true, + + /* + |-------------------------------------------------------------------------- + | Respect Ignore Docblock Tags + |-------------------------------------------------------------------------- + | Set to false in CI/CD runs to force type-checking on @typephp-ignore methods. + */ + 'respect_ignore_tags' => true, + + /* + |-------------------------------------------------------------------------- + | Enable Caching + |-------------------------------------------------------------------------- + | Pre-transforms and caches PHP files on disk for maximum speed. + */ + 'cache' => true, + + /* + |-------------------------------------------------------------------------- + | Registered Extensions + |-------------------------------------------------------------------------- + | Explicitly list third-party extension classes. + */ + 'extensions' => [ + // \Acme\Domain\TypePHPExtension::class, + ], + + /* + |-------------------------------------------------------------------------- + | Inline Variable Validation (@var $x = ...) + |-------------------------------------------------------------------------- + | Fine-grained control over local variable assignment checks. + */ + 'inline_vars' => [ + 'properties' => true, + 'generics' => true, + 'callables' => true, + 'scalars' => true, + 'arrays' => true, + 'objects' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Included Paths & Whitelisting + |-------------------------------------------------------------------------- + | Globs or specific file paths that should be intercepted and type-checked. + */ + 'include' => [ + 'src/**', + 'app/**', + 'internals/**', + 'tests/**', + // 'vendor/my-org/my-package/**', // Whitelist a specific vendor package + ], + + /* + |-------------------------------------------------------------------------- + | Excluded Paths + |-------------------------------------------------------------------------- + | Globs or specific file paths that should be ignored by the type checker. + */ + 'exclude' => [ + 'vendor/**', + 'storage/**', + 'var/**', + 'cache/**', + ], +]; +``` + +--- + +## Pattern Specificity Rules + +If a file matches both an `include` rule and an `exclude` rule, TypePHP compares pattern lengths: + +* **Specific Whitelist Wins:** `'vendor/my-org/package/**'` (length 25) takes precedence over `'vendor/**'` (length 8). +* **Single File Override:** `'src/LegacyFile.php'` (length 22) takes precedence over `'src/**'` (length 6). +* **Tie-Breaker:** If pattern lengths are equal, `exclude` takes precedence to ensure application safety. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md new file mode 100644 index 0000000..25ec924 --- /dev/null +++ b/docs/getting-started/installation.md @@ -0,0 +1,75 @@ +# Installation + +TypePHP requires **PHP 8.1 or higher**. + +## Installation via Composer + +Install TypePHP in your project using Composer: + +```bash +composer require typephp/typephp +``` + +## Initializing Configuration + +Generate a default `typephp.php` configuration file in your project root directory: + +```bash +vendor/bin/typephp config:init +``` + +--- + +## Bootstrapping + +TypePHP hooks into Composer's autoloader via `src/bootstrap.php`. Whenever `vendor/autoload.php` is required, TypePHP initializes automatically: + +```php + $id, + 'username' => $username, + 'role' => 'admin', + ]; +} + +// Valid Call +createUser(42, 'Alice'); + +// Invalid Call (Passing negative integer) +createUser(-5, 'Alice'); +// Throws: TypeError: createUser(): Argument $id must be of type positive-int, negative int (-5) given +``` + +--- + +## Inline Variable Validation (`@var`) + +Validate local variable assignments inside function bodies: + +```php +/** @var positive-int $age */ +$age = 25; // Valid + +$age = -10; +// Throws: TypeError: Variable $age must be of type positive-int, negative int (-10) given +``` + +--- + +## Runtime Generics with `WeakMap` + +TypePHP binds generic template types (`T`) directly to object instances: + +```php +use TypePHP\Tests\Fixtures\Generics\Collection; +use App\Models\User; +use App\Models\Product; + +/** @var Collection $users */ +$users = new Collection(); + +$users->add(new User('Alice')); // Valid + +$users->add(new Product('SKU-100')); +// Throws: TypeError: Argument $item (template T = User) must be of type User, Product given +``` + +--- + +## PHP 8.4 Property Hooks + +TypePHP validates incoming and returned values on PHP 8.4 Property Hooks: + +```php +class UserProfile +{ + /** @var positive-int */ + public private(set) int $id = 10; + + /** @var non-empty-string */ + public string $username { + get => $this->_username; + set => $this->_username = trim($value); + } + + private string $_username = 'Alice'; +} + +$profile = new UserProfile(); +$profile->username = ' '; +// Throws: TypeError: Property UserProfile::$username must be of type non-empty-string +``` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..000f9cc --- /dev/null +++ b/docs/index.md @@ -0,0 +1,49 @@ +--- +layout: home + +hero: + name: "TypePHP" + text: "Runtime Type Enforcement for PHP" + tagline: "Enforces PHPDoc generics, array shapes, integer ranges, and complex type contracts at runtime in development and production environments." + actions: + - theme: brand + text: Get Started → + link: /getting-started/installation + - theme: alt + text: View on GitHub + link: https://github.com/typephp/typephp + +features: + - title: Zero-Cost Performance + details: Pre-transforms AST code and caches files on disk. Runs at native OPCache RAM speed with constant O(1) memory lookup. + - title: True Runtime Generics + details: Prebinds template types (Collection) to object instances using WeakMap memory tracking. + - title: Production Ready + details: Selective path whitelisting allows type-checking mission-critical domain logic in production with zero risk. + - title: PHP 8.4 Support + details: Native support for PHP 8.4 Property Hooks (get/set) and Asymmetric Visibility (public private(set)). +--- + +## Example Usage + +```php +use App\Models\User; +use TypePHP\Tests\Fixtures\Generics\Collection; + +/** + * Enforce parameter and return contracts directly in PHPDoc annotations + * + * @param Collection $users + * @param array{status: 'active'|'pending', count: positive-int} $options + * @return list + */ +function processUserBatch(Collection $users, array $options): array +{ + /** @var positive-int $limit */ + $limit = $options['count']; + + return [10, 20, 30]; +} +``` + +--- \ No newline at end of file diff --git a/src/Command/CommandRunner.php b/src/Command/CommandRunner.php index 605981c..71b9d3f 100644 --- a/src/Command/CommandRunner.php +++ b/src/Command/CommandRunner.php @@ -4,13 +4,10 @@ namespace TypePHP\Command; -/** - * @internal Executes TypePHP CLI commands. - */ final class CommandRunner { /** - * @internal Parses CLI arguments and routes execution to the corresponding command class. + * Parses CLI arguments and routes execution to the corresponding command class. * * @param array $args * @param resource $outputStream @@ -18,24 +15,28 @@ 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); + $showHelp = in_array('help', $args, true) || in_array('typephp:help', $args, true) || in_array('--help', $args, true) || in_array('-h', $args, true); if ($showHelp || empty($args)) { return (new HelpCommand())->execute($args, $outputStream, $errorStream); } - if (\in_array('cache:rebuild', $args, true)) { + if (in_array('config:init', $args, true) || in_array('init', $args, true)) { + return (new ConfigInitCommand())->execute($args, $outputStream, $errorStream); + } + + if (in_array('cache:rebuild', $args, true)) { return (new CacheRebuildCommand())->execute($args, $outputStream, $errorStream); } - if (\in_array('cache:clear', $args, true)) { + if (in_array('cache:clear', $args, true)) { return (new CacheClearCommand())->execute($args, $outputStream, $errorStream); } - if (\in_array('cache:warm', $args, true)) { + if (in_array('cache:warm', $args, true)) { return (new CacheWarmCommand())->execute($args, $outputStream, $errorStream); } return (new RunCommand())->execute($args, $outputStream, $errorStream); } -} +} \ No newline at end of file diff --git a/src/Command/ConfigCommand.php b/src/Command/ConfigCommand.php new file mode 100644 index 0000000..fa2072a --- /dev/null +++ b/src/Command/ConfigCommand.php @@ -0,0 +1,159 @@ + false): TypePHP boots normally, but turns all + | runtime checks into instant no-ops (pass-through mode). + | - Bootstrap Prevention (TYPEPHP_DISABLE=true): To completely prevent TypePHP + | from booting or registering its stream wrapper during Composer autoload, + | set the environment variable TYPEPHP_DISABLE=true or define('TYPEPHP_DISABLE', true) + | before requiring 'vendor/autoload.php'. + */ + 'enabled' => true, + + /* + |-------------------------------------------------------------------------- + | Function Boundary Contracts (@param & @return) + |-------------------------------------------------------------------------- + | Controls whether function and method parameter/return contracts are enforced. + | When enabled, all parameter and return types (generics, shapes, scalars) + | are enforced uniformly to maintain type state consistency. + */ + 'params' => true, + 'returns' => true, + + /* + |-------------------------------------------------------------------------- + | Respect Ignore Docblock Tags + |-------------------------------------------------------------------------- + | When true (default), @typephp-ignore and @typephp-ignore-file docblock tags + | skip type-checking on specific methods/files. Set to false in CI/CD or + | audit runs to force type-checking on all ignored methods without deleting + | the docblock tags from source code. + */ + 'respect_ignore_tags' => true, + + /* + |-------------------------------------------------------------------------- + | Enable Caching + |-------------------------------------------------------------------------- + | When enabled, transformed PHP files are cached on disk for speed. + | Set to false to run AST transformations purely in RAM (php://memory). + */ + 'cache' => true, + + /* + |-------------------------------------------------------------------------- + | Registered Extensions + |-------------------------------------------------------------------------- + | Explicitly list third-party extension classes that provide path overrides. + */ + 'extensions' => [ + // \Acme\Domain\TypePHPExtension::class, + ], + + /* + |-------------------------------------------------------------------------- + | Inline Variable Validation (@var $x = ...) + |-------------------------------------------------------------------------- + | Fine-grained control over which type categories are enforced on local + | variable assignments with inline @var Type $var docblocks. + | + | Supported options: + | - 'properties': Validates class property assignments (e.g. $this->id = 1). + | - 'generics' : Prebinds generic template instances (e.g. Collection). + | - 'callables' : Wraps inline callbacks (e.g. callable(int): string). + | - 'scalars' : Enforces scalar constraints (e.g. positive-int, non-empty-string). + | - 'arrays' : Enforces array shapes, lists, & typed arrays (e.g. array{id: int}, int[]). + | - 'objects' : Enforces class instance checks (e.g. @var User $user). + */ + 'inline_vars' => [ + 'properties' => true, + 'generics' => true, + 'callables' => true, + 'scalars' => true, + 'arrays' => true, + 'objects' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Included Paths & Whitelisting + |-------------------------------------------------------------------------- + | Globs or specific file paths that should be intercepted and type-checked. + | + | Pattern Specificity: + | More specific patterns take precedence over broader rules. + | You can specify directory globs (e.g. 'src/**'), single vendor packages + | (e.g. 'vendor/my-org/my-package/**'), or single specific files + | (e.g. 'vendor/monolog/monolog/src/Monolog/Logger.php'). + */ + 'include' => [ + 'src/**', + 'app/**', + 'internals/**', + 'tests/**', + // 'vendor/my-org/my-package/**', // Whitelist a vendor package + ], + + /* + |-------------------------------------------------------------------------- + | Excluded Paths & Single-File Blacklisting + |-------------------------------------------------------------------------- + | Globs or specific file paths that should be ignored by the type checker. + | You can exclude entire directories (e.g. 'vendor/**') or blacklist + | single legacy files inside included directories (e.g. 'src/Legacy/File.php'). + */ + 'exclude' => [ + 'vendor/**', + 'storage/**', + 'var/**', + 'cache/**', + // 'src/Legacy/UnsafeFile.php', // Blacklist a single specific file + ], +]; +PHP; + } +} \ No newline at end of file diff --git a/src/Command/HelpCommand.php b/src/Command/HelpCommand.php index 771257f..dace295 100644 --- a/src/Command/HelpCommand.php +++ b/src/Command/HelpCommand.php @@ -4,27 +4,26 @@ namespace TypePHP\Command; -/** - * @internal Displays TypePHP CLI help menu. - */ final class HelpCommand implements CommandInterface { public function execute(array $args, $outputStream = STDOUT, $errorStream = STDERR): int { $c = [CliFormatter::class, 'color']; - fwrite($outputStream, "\n " . $c(' TYPEPHP ', 'badge_green') . ' ' . $c('Runtime Type Checker', 'bold') . "\n\n"); - fwrite($outputStream, ' ' . $c('USAGE', 'yellow') . "\n"); + fwrite($outputStream, "\n " . $c(' TYPEPHP ', 'badge_green') . " " . $c('Runtime Type Checker', 'bold') . "\n\n"); + fwrite($outputStream, " " . $c('USAGE', 'yellow') . "\n"); fwrite($outputStream, " vendor/bin/typephp \n\n"); - fwrite($outputStream, ' ' . $c('COMMANDS', 'yellow') . "\n"); - fwrite($outputStream, ' ' . $c('cache:clear', 'green') . " Clear all cached transformed files\n"); - fwrite($outputStream, ' ' . $c('cache:warm', 'green') . " Pre-transform and warm up cache for included files\n"); - fwrite($outputStream, ' ' . $c('cache:rebuild', 'green') . " Clear and immediately warm up cache\n"); - fwrite($outputStream, ' ' . $c('help', 'green') . " Display this help menu\n\n"); - fwrite($outputStream, ' ' . $c('EXAMPLES', 'yellow') . "\n"); + fwrite($outputStream, " " . $c('COMMANDS', 'yellow') . "\n"); + fwrite($outputStream, " " . $c('config:init', 'green') . " Generate default typephp.php configuration file\n"); + fwrite($outputStream, " " . $c('cache:clear', 'green') . " Clear all cached transformed files\n"); + fwrite($outputStream, " " . $c('cache:warm', 'green') . " Pre-transform and warm up cache for included files\n"); + fwrite($outputStream, " " . $c('cache:rebuild', 'green') . " Clear and immediately warm up cache\n"); + fwrite($outputStream, " " . $c('help', 'green') . " Display this help menu\n\n"); + fwrite($outputStream, " " . $c('EXAMPLES', 'yellow') . "\n"); + fwrite($outputStream, " vendor/bin/typephp config:init\n"); fwrite($outputStream, " vendor/bin/typephp index.php\n"); fwrite($outputStream, " vendor/bin/typephp cache:rebuild\n\n"); return 0; } -} +} \ No newline at end of file From 1e0a2183a12bb4405993c1439fcf63e8ffc17433 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 7 Aug 2026 00:10:43 +0800 Subject: [PATCH 02/15] Add comprehensive documentation and configuration setup for TypePHP, including architecture overview, installation instructions, and command usage examples. --- docs/architecture/how-it-works.md | 230 ++++++++++++++++++ docs/config.mts | 7 + docs/getting-started/configuration.md | 47 +++- docs/getting-started/installation.md | 59 +++-- docs/getting-started/quick-start.md | 128 ++++++++-- ...onfigCommand.php => ConfigInitCommand.php} | 0 tests/Command/CommandRunnerTest.php | 20 +- 7 files changed, 441 insertions(+), 50 deletions(-) create mode 100644 docs/architecture/how-it-works.md rename src/Command/{ConfigCommand.php => ConfigInitCommand.php} (100%) diff --git a/docs/architecture/how-it-works.md b/docs/architecture/how-it-works.md new file mode 100644 index 0000000..f1b7928 --- /dev/null +++ b/docs/architecture/how-it-works.md @@ -0,0 +1,230 @@ +# How It Works + +TypePHP provides configurable runtime type enforcement without requiring custom C-extensions or modified PHP binaries. It operates entirely in PHP user-land by leveraging PHP's native `StreamWrapper` subsystem, Abstract Syntax Tree (AST) transformations, and in-memory state tracking. + +--- + +## Parser Engine Dependencies + +TypePHP relies on two industry-standard parsing libraries to process source code and docblock contracts: + +* **`nikic/php-parser`:** Parses raw PHP source code into Abstract Syntax Tree (AST) statement nodes, allowing TypePHP to inspect assignments, function signatures, and property hooks to inject guard-rail expressions. +* **`phpstan/phpdoc-parser`:** Tokenizes and parses PHPDoc annotations (`@param`, `@return`, `@template`, `@var`, `@phpstan-type`) into strongly typed AST `TypeNode` objects. + +--- + +## The 5-Step Execution Lifecycle + +Whenever your application loads a PHP file via `require`, `include`, or Composer's autoloader, TypePHP processes the file through a 5-step lifecycle: + +``` +[require "file.php"] + │ + ▼ + 1. Stream Interception (StreamWrapper) + │ + ▼ + 2. Path & Specificity Filtering (FileFilter) + │ + ▼ + 3. AST Transformation & Injection (ContractVisitor) + │ + ▼ + 4. Zero Line-Drift Formatting & Caching (TypePHPPrinter) + │ + ▼ + 5. Runtime Type Enforcement (RuntimeTypeChecker) +``` + +--- + +## Stream Interception + +TypePHP registers a custom stream wrapper for PHP's native `file://` protocol using `stream_wrapper_register()`. + +When PHP attempts to include a file, TypePHP's `StreamWrapper` intercepts the `open` call. If the call is a read-only inspection (such as `file_get_contents()` or `token_get_all()`), TypePHP passes the raw file through untouched. If the call is an execution request (`require` or `include`), the file proceeds to path filtering. + +Because stream handlers hook directly into PHP's stream subsystem, all underlying stream read, write, and stat operations execute **natively at C-level speed inside Zend Engine**, ensuring zero user-land file I/O bottlenecks. + +--- + +## Path Filtering and Pattern Specificity + +TypePHP alone determines which files to transform and enforce docblock contracts on based on your `typephp.php` configuration: + +```php +'include' => ['src/**', 'vendor/my-org/my-package/**'], +'exclude' => ['vendor/**', 'storage/**'], +``` + +TypePHP calculates pattern specificity based on glob length. If you whitelist a specific vendor package (`'vendor/my-org/my-package/**'`), its pattern length (27) takes precedence over the general `'vendor/**'` exclusion (8). + +### Excluded Files and Whitelisted Boundaries + +When a file is excluded (blacklisted): + +1. **Zero AST Modification:** The excluded file remains 100% raw, untouched PHP code. No AST parsing or check injection occurs on the excluded file. +2. **Active Whitelisted Guard Rails:** If an excluded file calls a method inside an included/whitelisted file passing invalid data, **a `TypeError` is still thrown**. The type guard runs inside the whitelisted method, protecting the whitelisted code regardless of who called it. +3. **Caller Line Attribution:** Even though the blacklisted caller file was never modified, `ErrorFactory` inspects the call stack trace and attributes the `TypeError` file and line number directly to the exact call site inside the blacklisted file! + +--- + +## AST Transformation and Injection + +If the file is included, TypePHP parses the source code into an AST using `nikic/php-parser` and `phpstan/phpdoc-parser`. + +`ContractVisitor` traverses the AST and injects single-line guard rails: + +* **Function Entry:** Injects `RuntimeTypeChecker::setupScope()` at the top of the function to validate incoming parameters. +* **Function Return:** Wraps `return` statements with `RuntimeTypeChecker::checkReturn()`. +* **Local Assignments (`@var`):** Wraps `$x = $value` with `RuntimeTypeChecker::checkVariable()`. +* **Class Properties:** Wraps `$this->prop = $value` and PHP 8.4 Property Hooks with `RuntimeTypeChecker::checkProperty()`. +* **Callables & Iterators:** Wraps callbacks and generators in lazy proxies (`CallableWrapper`, `IterableWrapper`). + +--- + +## Zero Line-Drift Formatting and Caching + +A common issue with AST code injection is that adding new statements pushes subsequent code down, causing line numbers in error stack traces to drift. + +TypePHP solves this using `TypePHPPrinter` and regex post-processing. Injected guard rails are squashed onto single lines and appended to existing code blocks (such as the opening `{` of a function signature). + +**Line numbers in your source files remain 100% identical before and after transformation.** + +### Disk Caching + +Once transformed, TypePHP saves the resulting code to disk in `sys_get_temp_dir() . '/typephp-cache/'`. On all subsequent requests: +* AST parsing runs **0 times**. +* PHP's **OPCache** compiles the cached file once into bytecode in RAM. +* Stream file reads execute natively at C-level speed inside Zend Engine. + +--- + +## Typed Arrays and Array Shapes + +TypePHP handles complex array structures through specialized validators in `TypeValidatorRegistry`: + +### Array Shapes (`ArrayShapeValidator`) +For annotations like `array{id: positive-int, name: string, role?: 'admin'|'user'}`: +* **Required vs. Optional Keys:** Verifies that required keys (`id`, `name`) are present, while allowing optional keys (`role?`) to be omitted. +* **Sealed vs. Unsealed Shapes:** In sealed shapes (default), unexpected extra keys trigger a `TypeError`. Unsealed shapes (`array{id: int, ...}`) validate extra key-value pairs against the unsealed type specification. + +### Typed Arrays and Lists (`ArrayValidator` & `GenericValidator`) +For annotations like `int[]`, `User[]`, `list`, or `array`: +* **Sequential List Verification:** `list` uses PHP's native `array_is_list()` to ensure keys are sequential 0-indexed integers. +* **Object Memoization:** When validating an array of objects (such as `User[]`), `TypeValidatorRegistry` memoizes previously checked object instances in a `\WeakMap`. If the same object instance appears multiple times in a collection, its type is checked once and retrieved in O(1) time on subsequent accesses. + +--- + +## Lazy Proxies: Callables, Generators, and Iterators + +TypePHP uses lazy wrappers to validate dynamic data structures upon invocation or iteration without forcing eager evaluation: + +### Callable Wrapper (`CallableWrapper`) + +When a function accepts a `callable(int): string` parameter, `RuntimeTypeChecker::wrapCallable()` wraps the callback in an interceptor closure: +* **Invocation Validation:** When the callback is called, its incoming arguments are validated against the declared parameter types. +* **Return Validation:** When the callback returns, its return value is validated against the declared return type. +* **Static Closure Constraints:** Enforces `static-closure` rules, rejecting closures bound to `$this`. + +### Iterator Proxy (`IterableWrapper` & `IteratorProxy`) + +When an iterable or generator is passed into a function accepting `Traversable`: +* **Lazy Item Validation:** Values and keys are validated on-the-fly during iteration inside `current()` or `yield`. +* **Rewindability:** `IteratorProxy` unwraps and preserves iterator rewindability, allowing you to iterate over the wrapped Traversable in multiple `foreach` loops cleanly. +* **Method & Countable Forwarding:** Forwards `Countable::count()` and custom method calls directly to the inner iterator using `__call()`. +* **Generator `TSend` Input Validation:** `checkSend()` intercepts values passed via `$gen->send()` and validates them against the declared `TSend` template parameter. + +--- + +## Inheritance Tracking and In-Memory Reflection Caching + +TypePHP resolves method and property contracts across complex Object-Oriented hierarchies (abstract classes, parent classes, interfaces, PHP 8.4 interface properties, and traits) using `HierarchyResolver`. + +### Gap-Filling and Parameter Renaming + +* **Gap-Filling:** If a child method defines a docblock for `$name` but leaves `$id` un-annotated, `ContractParser` traverses up the hierarchy to fill in the missing contract for `$id` from parent classes or interfaces. +* **Parameter Renaming:** Inherited parameters are mapped by **index position** rather than parameter name. If a child class renames `$id` to `$userId`, the contract declared on `$id` at index 0 is mapped and enforced on `$userId`. +* **Vendor Isolation:** Inherited docblocks from files matching `exclude` rules (such as `/vendor/`) are ignored to prevent third-party docblock bugs from affecting your application. + +### In-Memory Static Reflection Caching + +To avoid repeating expensive Reflection calls across multiple method invocations on the same class, `HierarchyResolver` caches resolved `ReflectionClass` and `ReflectionMethod` trees in static RAM arrays (`$methodHierarchyCache` and `$classHierarchyCache`). + +The Reflection tree for a class is built **exactly once** and retrieved in O(1) nanoseconds on all subsequent calls. + +--- + +## Lexical Scope Tracking (`ScopeManager`) + +TypePHP tracks local `@var` annotations using `ScopeManager` during AST traversal. + +To support block-level variable scope isolation and prevent type contract leakage: +* **Scope Stack Frames:** Entering a function, closure, or control block (`if`, `elseif`, `else`, `foreach`, `while`, `for`, `try/catch`) pushes a new scope frame (`pushScope()`) that inherits outer variable contracts. +* **Variable Shadowing:** Re-declaring a variable type inside an `if` block (e.g. `/** @var non-empty-string $z */`) applies strictly inside that block. +* **Scope Restoration:** Exiting the block (`popScope()`) restores outer variables back to their original type contracts. Unexecuted branches (such as `if (false)`) never pollute the outer scope. + +--- + +## State Tracking Mechanics + +TypePHP manages generic templates and call scopes using specialized memory tracking: + +### Object Instance Generics (`WeakMap`) + +When you instantiate a generic object (such as `Collection`), `TemplateManager` binds template parameters (`T = User`) to that specific object instance using PHP's native `WeakMap`. + +Because `WeakMap` uses weak references, when the object instance is garbage-collected by PHP, its generic state is automatically deleted from memory with **zero memory leaks**. + +### Call Stack Scope Tracking (`ScopeCleaner`) + +For function-level templates (`@template T`), TypePHP pushes a temporary call frame when entering the function and returns a `ScopeCleaner` object. When the function exits or throws an exception, `ScopeCleaner::__destruct()` automatically pops the call frame, keeping generic state clean across recursive calls. + +--- + +Here is the updated, brief **Validation Error Messages and Trace Attribution** section for `docs/architecture/how-it-works.md`: + +--- + +## Validation Error Messages and Trace Attribution + +When a type contract fails, TypePHP constructs informative error messages through a 3-tier pipeline: + +``` +Raw Value + TypeNode AST / Template Context + │ + ▼ +1. Error Generation (Validators & TemplateManager) + │ + ▼ +2. Human-Readable Value Formatting (TypeFormatter) + │ + ▼ +3. Exception Packaging & Trace Attribution (ErrorFactory) +``` + +1. **Error Generation (`TypeValidatorRegistry` & `TemplateManager`):** + * **Standard Types:** Strategy validators evaluate values against AST nodes (`IdentifierValidator`, `ArrayShapeValidator`, `ObjectShapeValidator`, etc.). + * **Generics & Variance:** `TemplateManager` and `ParamChecker` construct generic error messages when template bounds (`template T = User`), class-strings (`class-string`), or variance rules (`Producer`) are violated. +2. **Human-Readable Formatting (`TypeFormatter`):** Inspects raw PHP values and generates descriptive string representations (e.g. `negative int (-50)`, `empty string ('')`, `associative array`, or object FQCN `App\Models\Car`). +3. **Exception Packaging (`ErrorFactory`):** Packages messages into `TypePHP\Exception\TypeError` that extends native `TypeError`. For parameter and callback argument errors, it filters out internal library frames and sets `$e->file` and `$e->line` to match the exact caller line in your application code. + +--- + +## Performance Model: Transparent Trade-Offs + +TypePHP is designed to be as fast as possible in PHP user-land, but runtime type checking inherently introduces CPU and memory trade-offs that you should understand: + +### Understanding the Overhead + +1. **Array Iteration (O(N) Overhead):** Validating a large array (e.g., 10,000 items) requires iterating every element. While small arrays (10–100 items) validate in microseconds, validating massive arrays adds measurable CPU overhead. +2. **Generic State Tracking:** Prebinding generic templates (`Collection`) allocates entries in `\WeakMap` memory and adds lookup overhead during method execution. +3. **AST Transformation:** Transforming a file for the first time takes a few milliseconds before the result is cached on disk. + +### You Choose Where to Enforce Checks + +TypePHP gives you granular control so you can choose where and when to pay the performance cost: + +* **Selective Path Whitelisting:** Type-check only mission-critical domain logic (`app/Domain/**`) while bypassing non-critical files completely. +* **Granular Toggles:** Turn off array checking (`inline_vars.arrays => false`) or scalar checking (`inline_vars.scalars => false`) on high-frequency internal loops while maintaining strict parameter and return boundaries (`params => true`, `returns => true`). +* **Environment Master Switch:** Disable TypePHP completely in environment builds (`enabled => false`) for 100% un-transformed, native PHP execution speed. diff --git a/docs/config.mts b/docs/config.mts index cda386a..2a61956 100644 --- a/docs/config.mts +++ b/docs/config.mts @@ -7,6 +7,7 @@ export default defineConfig({ nav: [ { text: 'Home', link: '/' }, { text: 'Documentation', link: '/getting-started/installation' }, + { text: 'Architecture', link: '/architecture/how-it-works' }, { text: 'CLI', link: '/production/cache-commands' }, { text: 'GitHub', link: 'https://github.com/typephp/typephp' } ], @@ -19,6 +20,12 @@ export default defineConfig({ { text: 'Configuration', link: '/getting-started/configuration' }, ] }, + { + text: 'Architecture', + items: [ + { text: 'How It Works', link: '/architecture/how-it-works' }, + ] + }, { text: 'Core Concepts', items: [ diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 47e55f2..d09a1ef 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -105,10 +105,49 @@ return [ --- -## Pattern Specificity Rules +## Configuration Reference -If a file matches both an `include` rule and an `exclude` rule, TypePHP compares pattern lengths: +### Global Master Switch (`enabled`) +The `enabled` flag acts as the master kill-switch for TypePHP. When set to `false`, the interceptor completely steps out of the way, and no runtime type checking is performed. -* **Specific Whitelist Wins:** `'vendor/my-org/package/**'` (length 25) takes precedence over `'vendor/**'` (length 8). +**Config vs. Environment Variables:** +While you can hardcode this value in `typephp.php`, it is highly recommended to bind this to your environment variables (e.g., `'enabled' => env('TYPEPHP_ENABLED', true)`). This allows you to easily toggle TypePHP across different environments: +* **Local/Testing:** Set to `true` to catch type errors during development. +* **Production:** Set to `false` for zero overhead, or `true` if you require absolute type safety in your production application. + +### Respect Ignore Tags (`respect_ignore_tags`) +Developers can bypass runtime checks on performance-critical loops or legacy methods by adding the `@typephp-ignore` tag to a docblock. +By default (`true`), TypePHP honors these tags and skips checking those specific methods. + +However, if you set this to `false`, TypePHP will **ignore the ignore tags** and enforce type-checking universally. This is incredibly useful for **CI/CD pipelines** or comprehensive test suites where you want to verify total type safety across the entire application without developers' local optimizations bypassing the tests. + +### Function Boundary Contracts (`params` & `returns`) +These options dictate whether TypePHP enforces the types defined in your method signatures and docblocks. +* `params`: Validates incoming arguments against `@param` tags. +* `returns`: Validates outbound data against `@return` tags. + +> **Why is there no fine-grained configuration for boundaries?** +> You might notice that `inline_vars` allows you to selectively disable specific type checks (like scalars or generics), but `params` and `returns` do not. **This is an intentional architectural decision.** +> +> Function boundaries represent the **public contract** of your application. If a method claims to return an `array`, that contract must be absolute. Allowing selective enforcement at boundaries (e.g., checking the `User` object but ignoring the `int` key) creates an unreliable, unpredictable API. +> +> Inline variables, on the other hand, represent **internal state**. We provide fine-grained controls for inline variables so developers can optimize internal loop performance (e.g., turning off heavy generic checks locally) without breaking the guarantees of the public API boundaries. + +### Caching (`cache`) +When enabled, TypePHP stores the transformed, type-injected versions of your PHP files on disk. Subsequent executions bypass the AST parsing phase entirely, resulting in near-native PHP execution speeds. + +### Extensions (`extensions`) +This array allows you to register custom type handlers or third-party TypePHP plugins. Provide the fully qualified class name (FQCN) of your extension to have it booted during TypePHP's initialization. + +### Inline Variable Validation (`inline_vars`) +Unlike boundaries, local variable assignments (using `@var` docblocks) offer granular control. You can toggle specific types of runtime checks on or off. For instance, you may want to ensure `objects` are strictly typed but disable `generics` checks if you are iterating over massive arrays and need to squeeze out extra micro-optimizations. + +--- + +## Path Resolution & Specificity Rules + +The `include` and `exclude` arrays determine which files TypePHP should analyze. If a file matches both an `include` rule and an `exclude` rule, TypePHP resolves the conflict by comparing the character length of the patterns: + +* **Specific Whitelist Wins:** `'vendor/my-org/package/**'` (length 25) takes precedence over `'vendor/**'` (length 8). This allows you to exclude an entire directory but whitelist a specific package inside it. * **Single File Override:** `'src/LegacyFile.php'` (length 22) takes precedence over `'src/**'` (length 6). -* **Tie-Breaker:** If pattern lengths are equal, `exclude` takes precedence to ensure application safety. +* **Tie-Breaker:** If pattern lengths are exactly equal, `exclude` takes precedence by default to ensure application safety and prevent unintended parsing errors. \ No newline at end of file diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 25ec924..b38b2cf 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -2,14 +2,28 @@ TypePHP requires **PHP 8.1 or higher**. -## Installation via Composer +--- + +## Recommended Installation (Development & Testing) + +TypePHP is primarily designed as a **development and testing dependency** to enforce strict runtime type safety during local development, Pest/PHPUnit test runs, and CI/CD build pipelines: + +```bash +composer require --dev typephp/typephp +``` + +--- -Install TypePHP in your project using Composer: +## Production Installation (Optional & Advanced) + +If you intend to use TypePHP in production to selectively enforce type contracts on mission-critical domain logic, payment gateways, or security boundaries, install it as a main dependency: ```bash composer require typephp/typephp ``` +--- + ## Initializing Configuration Generate a default `typephp.php` configuration file in your project root directory: @@ -20,46 +34,37 @@ vendor/bin/typephp config:init --- -## Bootstrapping - -TypePHP hooks into Composer's autoloader via `src/bootstrap.php`. Whenever `vendor/autoload.php` is required, TypePHP initializes automatically: +## Executing Individual Scripts via CLI -```php - **Note:** `vendor/bin/typephp` runs your script using your system's native PHP engine while activating TypePHP contract enforcement on the target script and all required application files. -## CLI Command Runner +--- -TypePHP provides a binary (`vendor/bin/typephp`) for running scripts and managing cache: +## Autoloading & Bootstrapping -```bash -# Generate default configuration file -vendor/bin/typephp config:init +TypePHP automatically integrates with Composer's autoloader via `src/bootstrap.php`. Whenever `vendor/autoload.php` is required in your application or test suite, TypePHP boots automatically: -# Execute a script with TypePHP enabled -vendor/bin/typephp index.php - -# Clear transformed disk cache -vendor/bin/typephp cache:clear +```php + $id, - 'username' => $username, - 'role' => 'admin', - ]; + // ... } // Valid Call -createUser(42, 'Alice'); +processUser(42, 'Alice'); // Invalid Call (Passing negative integer) -createUser(-5, 'Alice'); -// Throws: TypeError: createUser(): Argument $id must be of type positive-int, negative int (-5) given +processUser(-5, 'Alice'); +// Throws: TypeError: processUser(): Argument $id must be of type positive-int, negative int (-5) given +``` + +--- + +## Return Contracts (`@return`) + +TypePHP validates function return values before they are returned to the caller: + +```php +/** + * @return array{id: positive-int, status: 'active'|'pending'} + */ +function fetchUserData(int $id): array +{ + if ($id <= 0) { + return ['id' => $id, 'status' => 'active']; // Invalid: $id is not positive-int + } + + return ['id' => $id, 'status' => 'active']; +} + +fetchUserData(-10); +// Throws: TypeError: fetchUserData(): Return value['id'] must be of type positive-int +``` + +--- + +## Typed Arrays, Lists, and Shapes + +Enforce strict structure on arrays, sequential lists, and key-value maps: + +```php +/** + * @param list $scores + * @param array $headers + */ +function processBatch(array $scores, array $headers): void +{ + // ... +} + +// Valid Call +processBatch([10, 20, 30], ['Authorization' => 'Bearer token']); + +// Invalid Call (Associative array passed where sequential list was expected) +processBatch(['score' => 10], ['Authorization' => 'Bearer token']); +// Throws: TypeError: processBatch(): Argument $scores must be a list ``` --- @@ -71,7 +125,7 @@ $users->add(new Product('SKU-100')); --- -## PHP 8.4 Property Hooks +## PHP 8.4 Property Hooks & Asymmetric Visibility TypePHP validates incoming and returned values on PHP 8.4 Property Hooks: @@ -94,3 +148,49 @@ $profile = new UserProfile(); $profile->username = ' '; // Throws: TypeError: Property UserProfile::$username must be of type non-empty-string ``` + +--- + +## Suppressing Type Checks (`@typephp-ignore` & `@typephp-ignore-file`) + +TypePHP provides annotations to skip type enforcement on legacy code or performance-critical sections without removing docblock types. + +### Function & Method Level Suppression + +Add `@typephp-ignore` to a function or class method docblock to skip type-checking for that specific function: + +```php +/** + * @typephp-ignore + * @param positive-int $id + */ +function legacyProcess(int $id): void +{ + // TypePHP skips type enforcement for this specific function +} + +legacyProcess(-500); // Passes without error +``` + +Here is the updated section addressing the reader directly as **"you"**: + +### File-Level Suppression (`@typephp-ignore-file`) + +Place `@typephp-ignore-file` in a file-level docblock at the top of a file: + +```php + **Technical Note & Coding Convention:** +> Under the hood, TypePHP scans the raw file contents for `@typephp-ignore-file` before performing AST transformations, meaning the tag will function regardless of its position in the file. However, you should always place `@typephp-ignore-file` at the very top of the file (right after `toBe(0) - ->and($output)->toContain('USAGE') - ; + ->and($output)->toContain('USAGE'); + }); + + test('routes config:init command successfully', function () { + $stream = fopen('php://memory', 'r+'); + $exitCode = CommandRunner::run(['config:init'], $stream, $stream); + + rewind($stream); + $output = stream_get_contents($stream); + fclose($stream); + + expect($exitCode)->toBe(0) + ->and($output)->toContain('Configuration'); }); test('routes cache:clear command successfully', function () { @@ -50,7 +61,6 @@ fclose($stream); expect($exitCode)->toBe(1) - ->and($output)->toContain('Error') - ; + ->and($output)->toContain('Error'); }); -}); +}); \ No newline at end of file From 3e5a519f3bfde8b126674145ab403750dffca132 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 7 Aug 2026 03:15:08 +0800 Subject: [PATCH 03/15] Add more documentations --- docs/config.mts | 1 + docs/core-concepts/function-contracts.md | 245 +++++++ docs/core-concepts/generics-and-bounds.md | 655 ++++++++++++++++++ docs/core-concepts/inline-variables.md | 149 ++++ docs/core-concepts/property-validation.md | 255 +++++++ docs/core-concepts/type-aliases.md | 177 +++++ docs/getting-started/configuration.md | 54 +- .../unions-intersections-and-conditionals.md | 241 +++++++ 8 files changed, 1741 insertions(+), 36 deletions(-) create mode 100644 docs/core-concepts/function-contracts.md create mode 100644 docs/core-concepts/generics-and-bounds.md create mode 100644 docs/core-concepts/inline-variables.md create mode 100644 docs/core-concepts/property-validation.md create mode 100644 docs/core-concepts/type-aliases.md create mode 100644 docs/supported-types/unions-intersections-and-conditionals.md diff --git a/docs/config.mts b/docs/config.mts index 2a61956..46b3a0d 100644 --- a/docs/config.mts +++ b/docs/config.mts @@ -43,6 +43,7 @@ export default defineConfig({ { text: 'Arrays & Shapes', link: '/supported-types/arrays-and-shapes' }, { text: 'Callables & Closures', link: '/supported-types/callables-and-closures' }, { text: 'Iterators & Generators', link: '/supported-types/iterators-and-generators' }, + { text: 'Unions, Intersections & Conditionals', link: '/supported-types/unions-intersections-and-conditionals' }, ] }, { diff --git a/docs/core-concepts/function-contracts.md b/docs/core-concepts/function-contracts.md new file mode 100644 index 0000000..8f57cd9 --- /dev/null +++ b/docs/core-concepts/function-contracts.md @@ -0,0 +1,245 @@ +# Function Contracts + +Functions and methods form the public boundaries of your software modules. TypePHP enforces `@param` and `@return` annotations directly at function entry and exit points. + +--- + +## Parameter Contracts (`@param`) + +When you declare `@param` annotations on a function or class method, TypePHP validates all incoming arguments before entering the function body: + +> **Suppressing Function Contracts:** Need to skip type-checking on a legacy function or method? Add `@typephp-ignore` to its docblock. See [Ignore Annotations](/advanced/ignore-annotations) for full details. + +```php + **Execution Order Note:** Native PHP type hints (e.g., `int $id`, `string $username`) are evaluated by PHP's C-engine *before* function execution begins. TypePHP's extended PHPDoc contracts (e.g., `positive-int`, `non-empty-string`) execute at the very start of the function/method body. If a native type hint fails, PHP throws its native `TypeError` before TypePHP's guard rails run. + +--- + +## Class Methods (Instance & Static) + +All parameter and return contract rules apply identically to **instance methods** (`public`, `protected`, `private`) and **static methods**: + +```php +class UserService +{ + /** + * Instance Method Contract + * + * @param positive-int $id + * @return array{id: positive-int, name: non-empty-string} + */ + public function findUser(int $id): array + { + return ['id' => $id, 'name' => 'Alice']; + } + + /** + * Static Method Contract + * + * @param non-empty-string $role + * @return list + */ + public static function getRoleIds(string $role): array + { + return [10, 20, 30]; + } +} + +$service = new UserService(); + +// Invalid Instance Method Call ($id is negative) +$service->findUser(-10); +// Throws: TypeError: UserService::findUser(): Argument $id must be of type positive-int + +// Invalid Static Method Call ($role is empty string) +UserService::getRoleIds(''); +// Throws: TypeError: UserService::getRoleIds(): Argument $role must be of type non-empty-string +``` + +--- + +## Class Constructors (`__construct`) + +TypePHP fully validates class constructor arguments, supporting both standard constructors and **Constructor Property Promotion** (PHP 8.0+). + +### Promoted Properties (PHP 8.0+) + +Annotate promoted properties in the constructor's docblock using standard `@param` tags: + +```php +class Order +{ + /** + * @param positive-int $id + * @param non-empty-string $sku + * @param int<1, 100> $quantity + */ + public function __construct( + public int $id, + public string $sku, + public int $quantity + ) {} +} + +// Valid Instance +new Order(1, 'SKU-99', 5); + +// Invalid Instance ($id is negative) +new Order(-1, 'SKU-99', 5); +// Throws: TypeError: Order::__construct(): Argument $id must be of type positive-int +``` + +### Property `@var` Fallback for Un-Annotated Constructors + +If a constructor parameter is un-annotated (or lacks a `@param` tag), TypePHP automatically inspects the corresponding class property's `@var` docblock to infer the parameter contract: + +```php +class User +{ + /** + * @var string[] + */ + public array $roles; + + // Un-annotated constructor parameter inherits contract from $roles property docblock! + public function __construct(array $roles) + { + $this->roles = $roles; + } +} + +// Invalid Instance (element 1 is an integer) +new User(['admin', 12345]); +// Throws: TypeError: User::__construct(): Argument $roles[1] must be of type string, int (12345) given +``` + +--- + +## Return Contracts (`@return`) + +TypePHP validates `return` statements before values are returned to the caller: + +```php +/** + * @return array{id: positive-int, status: 'active'|'pending'} + */ +function getUserStatus(int $id): array +{ + if ($id <= 0) { + return ['id' => $id, 'status' => 'active']; // Invalid: $id is negative + } + + return ['id' => $id, 'status' => 'active']; +} + +getUserStatus(-10); +// Throws: TypeError: getUserStatus(): Return value['id'] must be of type positive-int +``` + +> **PHPStan and Psalm Compatibility:** TypePHP also recognizes `@phpstan-param`, `@phpstan-return`, `@psalm-param`, and `@psalm-return` annotations. + +--- + +## Variadic Parameter Contracts + +When a function or method accepts variadic arguments (`...$items`), TypePHP validates every element passed in the variadic argument list: + +```php +/** + * @param positive-int ...$ids + */ +function deleteUsers(int ...$ids): void +{ + // ... +} + +// Valid Call +deleteUsers(10, 20, 30); + +// Invalid Call (3rd variadic item violates positive-int) +deleteUsers(10, 20, -5); +// Throws: TypeError: deleteUsers(): Argument $ids[2] must be of type positive-int +``` + +--- + +## Fluent `$this` Identity Returns + +For fluent builder or service classes annotated with `@return $this`, TypePHP verifies strict object identity (`$result === $this`), preventing accidental instantiation of new instances: + +```php +class UserBuilder +{ + private string $name = ''; + + /** + * @return $this + */ + public function setName(string $name): self + { + $this->name = $name; + + return $this; // Valid: Strict $this identity + } + + /** + * @return $this + */ + public function cloneSelf(): self + { + return new self(); // Invalid: New instance returned instead of $this + } +} + +$builder = new UserBuilder(); +$builder->cloneSelf(); +// Throws: TypeError: UserBuilder::cloneSelf(): Return value must be $this instance +``` + +--- + +## Conditional Return Types + +TypePHP supports parameter-based conditional return types (`@return ($param is true ? TypeA : TypeB)`): + +```php +/** + * @param bool $asInt + * @param mixed $value + * @return ($asInt is true ? positive-int : non-empty-string) + */ +function formatValue(bool $asInt, mixed $value): mixed +{ + return $value; +} + +// Valid Calls +formatValue(true, 42); // Evaluates return type as positive-int +formatValue(false, 'hello'); // Evaluates return type as non-empty-string + +// Invalid Call +formatValue(true, 'not_an_int'); +// Throws: TypeError: formatValue(): Return value must be of type positive-int +``` diff --git a/docs/core-concepts/generics-and-bounds.md b/docs/core-concepts/generics-and-bounds.md new file mode 100644 index 0000000..827714e --- /dev/null +++ b/docs/core-concepts/generics-and-bounds.md @@ -0,0 +1,655 @@ +# Generics and Bounds + +## What Are Generics and Why Do You Need Them? + +Without generics, a container class (like a `Collection` or `List`) or a wrapper class (like a `Repository` or `Response`) can only accept or return `mixed` or `object`: + +```php +// Without Generics: +$users = new Collection(); +$users->add(new User('Alice')); +$users->add(new Product('SKU-100')); // Accidental bug! Collection holds mixed types. +``` + +To prevent bugs without generics, you would have to write repetitive runtime type checks (`if (!$item instanceof User) throw ...`) inside every loop or method. + +**Generics solve this problem.** Generics allow you to pass a type parameter (like ``) into a class or function signature. It parameterizes the container, telling PHP: *"This specific Collection instance holds ONLY User objects."* + +```php +// With Generics: +/** @var Collection $users */ +$users = new Collection(); +$users->add(new User('Alice')); // Valid +$users->add(new Product('SKU-100')); // TypePHP blocks this instantly at runtime! +``` + +PHP natively does not possess built-in generic syntax in executable statements (unlike languages like C# or Java). Modern PHP relies on PHPDoc annotations (`@template T`). TypePHP reads those PHPDoc annotations and actively enforces them during actual runtime execution! + +--- + +## How Runtime Generics Work + +TypePHP manages generic templates at two distinct execution levels: + +1. **Function-Level Templates:** Bound per call site (e.g., `collectSameType(...$items)`). +2. **Class-Level Templates:** Bound to specific object instances in memory using PHP's native `WeakMap` (e.g., `Collection`). + +--- + +## PHP Limitation: No Native `instanceof` on Generics + +In PHP, writing generic syntax directly in executable statements (such as `if ($obj instanceof Collection)`) is a PHP syntax error. + +In TypePHP, you declare generics exclusively in PHPDoc annotations (`@param Collection $users` or `/** @var Collection $users */`). TypePHP's AST engine intercepts these annotations and enforces generic contracts at runtime without altering native PHP syntax rules. + +--- + +## Basic Template Annotations (`@template T`) + +When a function uses `@template T` across multiple parameters, TypePHP infers `T` from the first argument and enforces consistency across all subsequent arguments: + +```php + + */ +function collectSameType(mixed ...$items): array +{ + return $items; +} + +// Valid Call (Infers T = int) +collectSameType(10, 20, 30); + +// Invalid Call (Infers T = int from item #1, then item #3 'invalid' fails T = int) +collectSameType(10, 20, 'invalid'); +// Throws: TypeError: collectSameType(): Argument $items[2] (template T = int) must be of type int +``` + +--- + +## Multiple Generic Templates (`@template T`, `@template U`) + +Functions and classes are not limited to a single template parameter. You can declare multiple independent generic templates (such as `T`, `U`, `K`, `V`): + +```php +/** + * @template T of Animal + * @template U of Car + * + * @param T $animal + * @param U $car + */ +function pairUp(Animal $animal, Car $car): void +{ + // T is bound to Animal subtype, U is bound to Car subtype +} + +// Valid Call (T = Dog, U = SportsCar) +pairUp(new Dog(), new SportsCar()); + +// Invalid Call (Swapped arguments: T receives Car, U receives Dog) +pairUp(new Car(), new Dog()); +// Throws: TypeError: pairUp(): Argument $animal (template T) must be an instance of Animal, Car given +``` + +Here is the new **Generics of Scalars, Refinements, and Array Shapes** section for `docs/core-concepts/generics-and-bounds.md`: + +--- + +## Generics of Scalars, Refinements, and Array Shapes, Etc.. + +Generic parameters (`T`) in TypePHP are not limited to object classes. You can bind generics to refined scalar types (`positive-int`, `non-empty-string`) or complex array shapes (`array{id: positive-int}`): + +### Generic Collections of Refined Scalars (`Collection`) + +```php +/** @var Collection $scores */ +$scores = new Collection(); + +$scores->add(100); // Valid + +$scores->add(-50); +// Throws: TypeError: Collection::add(): Argument $item (template T = positive-int) must be of type positive-int +``` + +### Generic Collections of Array Shapes (`Collection`) + +```php +/** @var Collection $userShapes */ +$userShapes = new Collection(); + +$userShapes->add(['id' => 1, 'name' => 'Alice']); // Valid + +$userShapes->add(['id' => -5, 'name' => 'Alice']); +// Throws: TypeError: Collection::add(): Argument $item['id'] must be of type positive-int +``` + +For full reference guides on all supported scalar refinements and array shape structures, see [Primitives & Scalars](/supported-types/primitives-and-scalars) and [Arrays & Shapes](/supported-types/arrays-and-shapes). + + +--- + +## First-Use Type Inference (Unannotated Generic Instances) + +If you instantiate a generic class without an inline `@var` prebinding annotation: + +```php +$collection = new Collection(); // No @var Collection annotation! +``` + +TypePHP automatically infers the template parameter `T` from the **first method call** executed on that object instance and locks `T` to that type in `WeakMap` memory for all subsequent calls: + +```php +// 1. First method call infers T = User and locks T to User for this instance! +$collection->add(new User('Alice')); + +// 2. Subsequent call succeeds because argument is a User +$collection->add(new User('Bob')); + +// 3. Subsequent call fails because T was locked to User on first use! +$collection->add(new Product('SKU-100')); +// Throws: TypeError: Collection::add(): Argument $item (template T = User) must be of type User, Product given +``` + +--- + +## Generics with Unions and Intersections + +TypePHP fully supports combining generic structures with Union (`|`) and Intersection (`&`) types: + +> **Deep Dive Guide:** For complete syntax rules, parenthesized intersections, and disjunctive normal forms, see the dedicated [Unions, Intersections & Conditionals](/supported-types/unions-intersections-and-conditionals) guide. + +### Generic Containers Holding Unions (`Collection`) + +Allow a generic container to hold multiple types specified in a union: + +```php +/** @var Collection $animals */ +$animals = new Collection(); + +$animals->add(new Dog()); // Valid +$animals->add(new Cat()); // Valid + +$animals->add(new Car()); // Invalid: Car is neither Dog nor Cat +// Throws: TypeError: Collection::add(): Argument $item (template T = Dog|Cat) must be of type (Dog | Cat) +``` + +### Unions of Generic Containers (`Producer | Producer`) + +Accept a union of separate generic container instances: + +```php +/** + * @param Producer|Producer $producer + */ +function handleAnimalProducer(Producer $producer): void +{ + // ... +} + +handleAnimalProducer(new Producer(new Dog())); // Valid +handleAnimalProducer(new Producer(new Cat())); // Valid + +handleAnimalProducer(new Producer(new Car())); // Invalid +// Throws: TypeError: Argument $producer must be of type Producer|Producer +``` + +### Generic Containers Holding Intersections (`Collection`) + +Enforce that generic items must implement multiple interfaces simultaneously: + +```php +/** @var Collection $collections */ +$collections = new Collection(); + +$collections->add(new CountableArrayAccess()); // Valid (Implements both) + +$collections->add(new CountableOnly()); // Invalid (Fails ArrayAccess interface) +// Throws: TypeError: Argument $item must be of type Countable&ArrayAccess +``` + +### Complex Unions of Intersections in Generics + +You can combine parenthesized unions and intersections inside generic parameters: + +```php +/** @var Collection<(Countable&ArrayAccess)|(Iterator&Countable)> $payload */ +$payload = new Collection(); + +$payload->add(new CountableArrayAccess()); // Valid +$payload->add(new ArrayIterator([1, 2])); // Valid +``` + +--- + +## Default Generic Templates (`@template T = DefaultType`) + +If a template parameter `T` cannot be inferred from function arguments, TypePHP uses the declared default type: + +```php +/** + * @template T = string + * + * @param mixed $value + * @return T + */ +function getDefaultValue(mixed $value): mixed +{ + return $value; +} + +// Valid Call (Unbound T falls back to default: string) +getDefaultValue('valid_string'); + +// Invalid Call (Return value violates default string type) +getDefaultValue(12345); +// Throws: TypeError: getDefaultValue(): Return value must be of type string +``` + +--- + +## Upper Bounds with Defaults (`@template T of Bound = DefaultType`) + +Combine upper bounds with defaults (`@template T of Bound = DefaultType`) to enforce class inheritance while providing a fallback type: + +```php +/** + * @template T of object = stdClass + * + * @param mixed $value + * @return T + */ +function getObjectInstance(mixed $value): mixed +{ + return $value; +} + +// Valid Call (stdClass satisfies default stdClass) +getObjectInstance(new stdClass()); + +// Invalid Call (Dog is an object, but violates default stdClass) +getObjectInstance(new Dog()); +// Throws: TypeError: getObjectInstance(): Return value must be an instance of stdClass +``` + +--- + +## Generic Template Bounds (`@template T of Bound`) + +Use `@template T of Bound` to restrict template arguments to a specific class, interface, or scalar range: + +```php +abstract class Animal {} +class Dog extends Animal {} +class Car {} // Not an Animal! + +/** + * @template T of Animal + * + * @param T $animal + * @return T + */ +function processAnimal(Animal $animal): Animal +{ + return $animal; +} + +// Valid Call (Dog extends Animal) +processAnimal(new Dog()); + +// Invalid Call (Car does not extend Animal) +processAnimal(new Car()); +// Throws: TypeError: processAnimal(): Argument $animal (template T) must be an instance of Animal, Car given +``` + +--- + +## Nested Generics (`Collection>`) + +TypePHP recursively evaluates deeply nested generic structures: + +```php +/** @var Collection> $producers */ +$producers = new Collection(); + +// Valid Addition +$producers->add(new Producer(new Dog())); + +// Invalid Addition (Producer holding Car instead of Dog) +$producers->add(new Producer(new Car())); +// Throws: TypeError: Argument $item must be an instance of Producer +``` + +--- + +## Class Inheritance (`@extends` and `@implements`) + +When a child class extends a generic parent class or implements a generic interface, declare the template mapping using `@extends` or `@implements` (also recognized as `@template-extends` and `@template-implements`): + +```php +/** + * Generic Interface + * + * @template T + */ +interface ProcessorInterface +{ + /** + * @param T $item + * @return T + */ + public function process(mixed $item): mixed; +} + +/** + * Fulfills T = Cat via @implements + * + * @implements ProcessorInterface + */ +class CatProcessor implements ProcessorInterface +{ + public function process(mixed $item): mixed + { + return $item; + } +} + +$processor = new CatProcessor(); + +// Valid Call +$processor->process(new Cat()); + +// Invalid Call +$processor->process(new Dog()); +// Throws: TypeError: CatProcessor::process(): Argument $item (template T = Cat) must be of type Cat +``` + +--- + +## Real-World Example 1: Generic Collections (`Collection`) + +TypePHP binds template types to object instances using `\WeakMap`. Prebind a generic collection using an inline `@var` annotation: + +```php +namespace App\Collections; + +use App\Models\User; +use App\Models\Product; + +/** + * @template T + */ +class Collection +{ + /** @var array */ + private array $items = []; + + /** + * @param T $item + */ + public function add(mixed $item): static + { + $this->items[] = $item; + return $this; + } + + /** + * @return array + */ + public function toArray(): array + { + return $this->items; + } +} + +// Prebind T = User to the $users object instance in WeakMap memory +/** @var Collection $users */ +$users = new Collection(); + +// Valid Addition +$users->add(new User('Alice')); + +// Invalid Addition (Product is not a User) +$users->add(new Product('SKU-999')); +// Throws: TypeError: Collection::add(): Argument $item (template T = User) must be of type User, Product given +``` + +--- + +## Real-World Example 2: Generic Repositories (`Repository`) + +When a class extends a generic parent class (`@extends BaseRepository`), TypePHP automatically resolves and inherits the parent's generic template bindings: + +```php +namespace App\Repositories; + +use App\Models\User; + +/** + * @template T + */ +abstract class BaseRepository +{ + /** + * @param T $entity + */ + public function save(mixed $entity): void + { + // ... + } +} + +/** + * Fulfills T = User via @extends + * + * @extends BaseRepository + */ +class UserRepository extends BaseRepository +{ +} + +$userRepo = new UserRepository(); + +// Valid Save +$userRepo->save(new User('Alice')); + +// Invalid Save +$userRepo->save(new Product('SKU-100')); +// Throws: TypeError: UserRepository::save(): Argument $entity (template T = User) must be of type User +``` + +--- + +## Real-World Example 3: `class-string` Factories + +Use `class-string` to bind template `T` from a class name string and enforce matching return types: + +```php +/** + * Generic Factory Function + * + * @template T of object + * + * @param class-string $class + * @return T + */ +function makeInstance(string $class): object +{ + return new $class(); +} + +// Valid Call (Returns Dog instance matching class-string) +$dog = makeInstance(Dog::class); +``` + +--- + +## Demystifying Variance (`covariant`, `contravariant`, `invariant`) + +Generic variance controls how subtype relationships between underlying types affect the generic container. If `Dog` is a subclass of `Animal`, how does `Producer` relate to `Producer`? + +### Inline Variance Syntax + +In addition to class-level declarations (`@template-covariant T` / `@template-contravariant T`), TypePHP supports declaring variance inline directly on function parameter and return type hints: + +```php +/** + * @param Repository $repo + * @param Consumer $consumer + * @return Producer + */ +function processContracts(Repository $repo, Consumer $consumer): Producer +{ + // ... +} +``` + +--- + +### 1. Invariance (Default / Read-Write) + +By default, generics in TypePHP (and PHPStan) are **invariant**. Invariance requires an **exact type match**. + +```php +/** @template T */ +class Box { public function __construct(public mixed $item) {} } + +/** @param Box $box */ +function checkBox(Box $box): void {} + +checkBox(new Box(new Animal())); // Valid +checkBox(new Box(new Dog())); // Invalid in invariant mode! +``` + +**Why?** If `checkBox` modifies `$box->item = new Cat()`, putting a `Cat` into a `Box` would corrupt the box! Invariance prevents this. + +--- + +### 2. Covariance (`@template-covariant T` / Producer Mindset) + +Covariance allows **subtypes** (`Dog` for `Animal`). Think of covariance as a **Producer / Read-Only** relationship. + +If a function only *reads* from a container producing `Animal`s, passing a container producing `Dog`s is completely safe—because every `Dog` read out of the container is guaranteed to be an `Animal`! + +```php +/** + * @template-covariant T + */ +class Producer +{ + public function __construct(public mixed $item) {} +} + +/** + * Accepts Producer holding Animal or any subtype of Animal (Dog, Cat) + * + * @param Producer $producer + */ +function handleProducer(Producer $producer): mixed +{ + return $producer->item; +} + +// Valid (Dog is a subtype of Animal) +handleProducer(new Producer(new Dog())); + +// Invalid (Car is not an Animal) +handleProducer(new Producer(new Car())); +// Throws: TypeError: handleProducer() expects Producer, but Producer was given +``` + +--- + +### 3. Contravariance (`@template-contravariant T` / Consumer Mindset) + +Contravariance allows **supertypes** (`Animal` for `Dog`). Think of contravariance as a **Consumer / Write-Only** relationship. + +If a function needs a handler that consumes a `Dog`, giving it a handler that can consume any `Animal` is completely safe—because an `Animal` handler can process any `Dog` given to it! + +```php +class Puppy extends Dog {} + +/** + * @template-contravariant T + */ +class Consumer +{ + /** + * @param callable(T): void $handler + */ + public function __construct(public mixed $handler) {} + + /** + * @param T $item + */ + public function consume(mixed $item): void + { + ($this->handler)($item); + } +} + +/** + * Accepts Consumer designed for Dog or any supertype of Dog (Animal) + * + * @param Consumer $consumer + */ +function processDogConsumer(Consumer $consumer, Dog $dog): void +{ + $consumer->consume($dog); +} + +// Valid: Animal handler can safely consume a Dog! +$animalHandler = fn (Animal $a) => null; +processDogConsumer(new Consumer($animalHandler), new Dog()); + +// Invalid: Puppy handler cannot handle any general Dog! +$puppyHandler = fn (Puppy $p) => null; +processDogConsumer(new Consumer($puppyHandler), new Dog()); +// Throws: TypeError: processDogConsumer() expects Consumer, but Consumer was given +``` + +### Variance Precedence Rules + +What happens if an inline type hint specifies `Consumer`, but the class definition declared `@template-contravariant T`? + +TypePHP resolves variance conflicts using **Usage-Site Precedence**: + +1. **Usage-Site Override:** If a function parameter or return type explicitly specifies an inline variance modifier (`covariant` or `contravariant`), **the usage-site modifier takes precedence**. +2. **Class-Level Fallback:** If the call site uses standard syntax (`Consumer`), TypePHP falls back to the class's declared `@template-covariant` or `@template-contravariant` rule. + +```php +/** + * Class declares Contravariant T (Default: Consumer / Supertypes) + * + * @template-contravariant T + */ +class Consumer +{ + public function __construct(public mixed $handler) {} +} + +/** + * Function parameter EXPLICITLY overrides with inline 'covariant Animal' + * + * @param Consumer $consumer + */ +function processCovariantConsumer(Consumer $consumer): mixed +{ + return $consumer->handler; +} + +// 1. Valid Call (Dog is a subtype of Animal) +// Class declared contravariant, BUT function parameter explicitly specified 'covariant'. +// Usage-site 'covariant' wins! +processCovariantConsumer(new Consumer(new Dog())); + +// 2. Invalid Call (Car is not an Animal) +processCovariantConsumer(new Consumer(new Car())); +// Throws: TypeError: processCovariantConsumer() expects Consumer, but Consumer was given \ No newline at end of file diff --git a/docs/core-concepts/inline-variables.md b/docs/core-concepts/inline-variables.md new file mode 100644 index 0000000..d22864a --- /dev/null +++ b/docs/core-concepts/inline-variables.md @@ -0,0 +1,149 @@ +# Inline Variables (`@var`) + +While parameter and return contracts protect function boundaries, inline `@var` annotations enforce type safety on local variable assignments and reassignments inside function bodies or php file execution lines. + +--- + +## Basic Variable Validation + +When you write an inline `@var` docblock above a local variable assignment, TypePHP validates the assigned value before the assignment executes: + +```php +`, `int[]` +* **Objects:** `/** @var \App\Models\User $user */` +* **Unions & Intersections:** `positive-int|non-empty-string`, `\Countable&\ArrayAccess` +* **Generics:** `/** @var Collection $users */` +* **Callables:** `/** @var callable(positive-int): non-empty-string $formatter */` + +--- + +## Single-Line vs. Multi-Line Annotations + +TypePHP supports both single-variable single-line docblocks and multi-variable docblocks (such as for `list()` array destructuring or multiple assignments): + +### Single-Variable Annotation + +```php +/** + * @var positive-int $id -> multiline docblock + */ +$id = 100; + +/** @var non-empty-string $name -> single-line docblock */ +$name = 'Reymart' +``` + +### Multi-Variable Annotation (Array Destructuring) + +When assigning multiple variables simultaneously via `list()` or `[$a, $b]`, declare all `@var` tags inside a single multi-line docblock: + +```php +/** + * @var positive-int $id + * @var non-empty-string $username + */ +[$id, $username] = [42, 'Reymart']; // Valid + +[$id, $username] = [-5, 'Reymart']; +// Throws: TypeError: Variable $id must be of type positive-int +``` + +--- + +## Unnamed Variable Annotations (`/** @var Type */`) + +If you omit the variable name from an inline `@var` docblock, TypePHP automatically infers the target variable name directly from the assignment statement: + +```php +/** @var positive-int */ +$count = 100; // TypePHP automatically infers that $count is positive-int! + +$count = -5; +// Throws: TypeError: Variable $count must be of type positive-int, negative int (-5) given +``` + +Both `/** @var positive-int $count */` and `/** @var positive-int */` behave identically. + +--- + +## Block-Level Scope Isolation & Shadowing + +TypePHP tracks variable type contracts using **Lexical Block Scope Frames**. + +When an inline `@var` tag is declared inside a control block (`if`, `elseif`, `else`, `foreach`, `while`, `for`, `try/catch`), the type contract applies **strictly inside that block**: + +```php +/** @var positive-int $z */ +$z = 10; // Outer contract: positive-int + +if ($condition) { + /** @var non-empty-string $z */ // Inner shadow contract: non-empty-string + $z = 'hello'; +} + +// Outside the if-block, $z reverts back to its outer contract: positive-int! +$z = -5; +// Throws: TypeError: Variable $z must be of type positive-int, negative int (-5) given +``` + +> **Unexecuted Code Protection:** Unexecuted branches (such as `if (false)`) never pollute outer scope variable contracts during execution. + +--- + +## Closure Scope Preservation + +When a local variable is captured by a short closure (arrow function `fn()`) or a long closure (`use ($var)` or `use (&$var)`), TypePHP inherits and enforces the outer variable contract inside the closure: + +```php +/** @var positive-int $id */ +$id = 10; + +// 1. Short Closure (Arrow Function) +$arrowFn = fn () => $id = -5; +$arrowFn(); +// Throws: TypeError: Variable $id must be of type positive-int + +// 2. Long Closure (By-Value Capture) +$closure = function () use ($id) { + $id = -50; +}; +$closure(); +// Throws: TypeError: Variable $id must be of type positive-int + +// 3. Long Closure (By-Reference Capture) +$refClosure = function () use (&$id) { + $id = -99; +}; +$refClosure(); +// Throws: TypeError: Variable $id must be of type positive-int +``` + +--- + +## Fine-Grained Configuration Control + +You can enable or disable specific categories of inline variable validation in `typephp.php` without turning off function parameter or return contracts: + +```php +'inline_vars' => [ + 'properties' => true, // Class property assignments ($this->id = 1) + 'generics' => true, // Generic instance prebinding (Collection) + 'callables' => true, // Inline callback wrapping (callable(int): string) + 'scalars' => true, // Scalar constraints (positive-int, non-empty-string) + 'arrays' => true, // Array shapes and lists (array{id: int}, list) + 'objects' => true, // Class instance checks (@var User $user) +], +``` diff --git a/docs/core-concepts/property-validation.md b/docs/core-concepts/property-validation.md new file mode 100644 index 0000000..092a350 --- /dev/null +++ b/docs/core-concepts/property-validation.md @@ -0,0 +1,255 @@ +# Property Validation + +TypePHP validates class property assignments and PHP 8.4 Property Hooks against declared `@var` annotations. + +--- + +## Instance and Static Property Assignments + +When you annotate a class property with `@var`, TypePHP intercepts assignments to that property and validates the value before the write occurs: + +```php +id = $newId; // Validated against @var positive-int + } + + public static function updateTitle(string $newTitle): void + { + self::$appTitle = $newTitle; // Validated against @var non-empty-string + } +} + +$config = new ConfiguredProperty(); + +// Valid Update +$config->updateId(42); + +// Invalid Update ($newId is negative) +$config->updateId(-5); +// Throws: TypeError: Property App\Models\ConfiguredProperty::$id must be of type positive-int, negative int (-5) given + +// Invalid Static Update ($newTitle is empty string) +ConfiguredProperty::updateTitle(''); +// Throws: TypeError: Property App\Models\ConfiguredProperty::$appTitle must be of type non-empty-string +``` + +--- + +## PHP 8.1+ Readonly Properties and Classes + +TypePHP validates PHP 8.1+ `readonly` properties and PHP 8.2+ `readonly` classes during initialization: + +```php +class Order +{ + /** + * @var positive-int + */ + public readonly int $id; + + /** + * @param non-empty-string $sku + */ + public function __construct( + int $id, + public readonly string $sku + ) { + $this->id = $id; // Validated against @var positive-int! + } +} + +// Valid Instance +$order = new Order(100, 'SKU-500'); + +// Invalid Instance ($id is negative) +new Order(-50, 'SKU-500'); +// Throws: TypeError: Property Order::$id must be of type positive-int +``` + +--- + +## PHP 8.4 Asymmetric Visibility (`public private(set)`) + +TypePHP seamlessly supports PHP 8.4 Asymmetric Property Visibility. Type contracts on asymmetric properties are enforced when writes occur inside authorized class methods: + +```php +class UserProfile +{ + /** + * Public read, private set + * + * @var positive-int + */ + public private(set) int $id = 10; + + public function setId(int $newId): void + { + $this->id = $newId; // TypePHP validates assignment inside the class! + } +} + +$profile = new UserProfile(); +$profile->setId(-100); +// Throws: TypeError: Property UserProfile::$id must be of type positive-int +``` + +--- + +## PHP 8.4 Property Hooks (`get` & `set`) + +TypePHP intercepts PHP 8.4 `get` and `set` property hooks, validating incoming values on `set` hooks and returned values on `get` hooks: + +```php +class PropertyHookDemo +{ + /** + * @var int[] + */ + public array $shortGetNumbers { + get => ['hello', 1]; // Returns invalid string 'hello' instead of int + } + + /** + * @var positive-int + */ + public int $shortSetNumber { + set => $this->_shortSetNumber = $value; // Validates incoming $value + } + + public int $_shortSetNumber = 10; +} + +$demo = new PropertyHookDemo(); + +// Invalid Get Hook Return +$value = $demo->shortGetNumbers; +// Throws: TypeError: Property PropertyHookDemo::$shortGetNumbers[0] must be of type int, string 'hello' given + +// Invalid Set Hook Write +$demo->shortSetNumber = -5; +// Throws: TypeError: Property PropertyHookDemo::$shortSetNumber must be of type positive-int +``` + +--- + +## PHP 8.4 Interface Property Inheritance + +If a class implements a PHP 8.4 Interface containing property hooks, the implementing class property inherits the interface property's `@var` docblock contract: + +```php +interface HookedInterfaceProperty +{ + /** + * @var positive-int + */ + public int $interfaceProp { get; } +} + +class HookedInterfaceImplementation implements HookedInterfaceProperty +{ + // Inherits @var positive-int from HookedInterfaceProperty interface! + public int $interfaceProp { + get => $this->_val; + } + + public int $_val = 10; +} + +$fixture = new HookedInterfaceImplementation(); +$fixture->_val = -5; + +$value = $fixture->interfaceProp; +// Throws: TypeError: Property HookedInterfaceImplementation::$interfaceProp must be of type positive-int +``` + +--- + +## Trait Property Inheritance + +Properties declared inside Traits inherit their `@var` docblock contracts when used by a class: + +```php +trait TraitWithProperties +{ + /** + * @var positive-int + */ + public int $traitInstanceProp = 10; + + public function setTraitProp(int $val): void + { + $this->traitInstanceProp = $val; + } +} + +class ClassUsingTrait +{ + use TraitWithProperties; +} + +$app = new ClassUsingTrait(); +$app->setTraitProp(-50); +// Throws: TypeError: Property ClassUsingTrait::$traitInstanceProp must be of type positive-int +``` + +--- + +## Generic Template Substitution in Properties + +When a class property uses a class-level generic template (`@var array $items`), TypePHP automatically substitutes `T` with the object instance's bound generic type: + +```php +/** + * @template T + */ +class GenericHookedCollection +{ + /** + * @var array + */ + public array $items = [] { + set => $this->items = $value; + } +} + +/** @var GenericHookedCollection $collection */ +$collection = new GenericHookedCollection(); + +$collection->items = [10, -50]; +// Throws: TypeError: Property GenericHookedCollection::$items[1] must be of type positive-int +``` + +--- + +## Configuration Control + +Property assignment validation is enabled by default. You can toggle property validation in `typephp.php`: + +```php +'inline_vars' => [ + 'properties' => true, // Set to false to disable property assignment checks +], +``` diff --git a/docs/core-concepts/type-aliases.md b/docs/core-concepts/type-aliases.md new file mode 100644 index 0000000..2cfa125 --- /dev/null +++ b/docs/core-concepts/type-aliases.md @@ -0,0 +1,177 @@ +# Type Aliases + +TypePHP supports declaring local type aliases (`@phpstan-type` / `@psalm-type`) and importing type aliases from other classes (`@phpstan-import-type` / `@psalm-import-type`). This allows you to centralize and reuse complex array shapes, unions, and generic structures across your application. + +> **Tooling Compatibility:** Both PHPStan syntax (`@phpstan-type`, `@phpstan-import-type`) and Psalm syntax (`@psalm-type`, `@psalm-import-type`) are parsed identically and enforced at runtime. + +--- + +## Local Type Aliases (`@phpstan-type` / `@psalm-type`) + +Declare a local type alias above a class or interface definition using `@phpstan-type` or `@psalm-type`. Once declared, you can reference the alias in any parameter, return, or `@var` docblock within that class: + +```php +updateUser(['id' => 10, 'username' => 'Alice', 'role' => 'admin'], 'active'); + +// Invalid Call ($id is negative, violating UserShape) +$service->updateUser(['id' => -5, 'username' => 'Alice', 'role' => 'admin'], 'active'); +// Throws: TypeError: UserService::updateUser(): Argument $user['id'] must be of type positive-int +``` + +--- + +## Imported Type Aliases (`@phpstan-import-type` / `@psalm-import-type`) + +To share type aliases across multiple classes, declare your aliases in a central class (e.g. `GlobalTypes`) and import them into other classes using `@phpstan-import-type` or `@psalm-import-type`: + +### Central Type Definitions (`GlobalTypes.php`) + +```php +namespace App\Types; + +/** + * Shared Type Definitions + * + * @phpstan-type SharedUserShape array{id: positive-int, email: non-empty-string} + * @psalm-type SharedRole 'admin'|'user' + */ +class GlobalTypes +{ +} +``` + +### Importing the Shared Type Alias (`UserApi.php`) + +```php +namespace App\Api; + +use App\Types\GlobalTypes; + +/** + * Import shared types from GlobalTypes + * + * @phpstan-import-type SharedUserShape from GlobalTypes + * @psalm-import-type SharedRole from GlobalTypes + */ +class UserApi +{ + /** + * @param SharedUserShape $user + * @param SharedRole $role + */ + public function saveUser(array $user, string $role): bool + { + return true; + } +} + +$api = new UserApi(); + +// Valid Call +$api->saveUser(['id' => 42, 'email' => 'alice@example.com'], 'admin'); + +// Invalid Call ($email is empty string) +$api->saveUser(['id' => 42, 'email' => ''], 'admin'); +// Throws: TypeError: UserApi::saveUser(): Argument $user['email'] must be of type non-empty-string +``` + +--- + +## Importing with Local Alias Renaming (`as`) + +Use the `as` keyword to rename an imported type alias locally to prevent naming collisions or improve local code clarity: + +```php +namespace App\Services; + +use App\Types\GlobalTypes; + +/** + * Import and rename the shared type alias + * + * @phpstan-import-type SharedUserShape from GlobalTypes as LocalUserShape + */ +class AccountService +{ + /** + * @param LocalUserShape $payload + */ + public function createAccount(array $payload): void + { + // ... + } +} +``` + +--- + +## Naming Collisions (When `as` is Omitted) + +If a class defines a local `@phpstan-type Status` AND imports a type alias with the exact same name (`@phpstan-import-type Status from GlobalTypes`) **without using the `as` keyword**: + +1. **Resolution Priority:** The imported type alias will **overwrite** the local type alias. +2. **Best Practice:** Always use the `as` keyword whenever an imported alias name collides with a local alias name to make your type contracts explicit: + +```php +/** + * Local alias: 'active'|'pending' + * @phpstan-type Status 'active'|'pending' + * + * Imported alias renamed to GlobalStatus to prevent overwriting local 'Status' + * @phpstan-import-type Status from GlobalTypes as GlobalStatus + */ +class OrderService +{ + // ... +} +``` + +--- + +## Chained Type Alias Imports + +TypePHP recursively resolves multi-level type alias import chains down to the root definition: + +* **Level 1 (`GlobalTypes`):** Defines `@phpstan-type UserShape array{id: positive-int}`. +* **Level 2 (`MidService`):** Imports `@phpstan-import-type UserShape from GlobalTypes`. +* **Level 3 (`FinalService`):** Imports `@phpstan-import-type UserShape from MidService as LocalShape`. + +When `FinalService` validates `$payload` against `LocalShape`, TypePHP automatically follows the 3-class import chain back to `GlobalTypes` and enforces `array{id: positive-int}`! + +```php +$service = new FinalService(); + +// Valid Call +$service->process(['id' => 100]); + +// Invalid Call (id is negative) +$service->process(['id' => -5]); +// Throws: TypeError: FinalService::process(): Argument $payload['id'] must be of type positive-int +``` diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index d09a1ef..fd4232b 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -21,6 +21,7 @@ return [ | Global Master Switch |-------------------------------------------------------------------------- | Controls whether TypePHP enforces type checks at runtime. + | Set to false for an emergency kill-switch or zero-overhead benchmarking. */ 'enabled' => true, @@ -105,49 +106,30 @@ return [ --- -## Configuration Reference +## Inline Variable Categories Reference (`inline_vars`) -### Global Master Switch (`enabled`) -The `enabled` flag acts as the master kill-switch for TypePHP. When set to `false`, the interceptor completely steps out of the way, and no runtime type checking is performed. +How each `inline_vars` toggle maps to PHPDoc type annotations: -**Config vs. Environment Variables:** -While you can hardcode this value in `typephp.php`, it is highly recommended to bind this to your environment variables (e.g., `'enabled' => env('TYPEPHP_ENABLED', true)`). This allows you to easily toggle TypePHP across different environments: -* **Local/Testing:** Set to `true` to catch type errors during development. -* **Production:** Set to `false` for zero overhead, or `true` if you require absolute type safety in your production application. +| Config Option | Covered PHPDoc Types | Examples | +| :--- | :--- | :--- | +| **`'scalars'`** | Primitive & Refined Scalars | `int`, `string`, `bool`, `positive-int`, `non-empty-string`, `truthy` | +| **`'objects'`** | Class Instances & Bare Class References | `User`, `stdClass`, `class-string`, `interface-string`, `enum-string` | +| **`'generics'`** | Template & Bound Types | `Collection`, `Producer`, `class-string` | +| **` illegible 'arrays'`** | All Arrays, Shapes, & Lists | `array{id: int}`, `int[]`, `User[]`, `list`, `array` | +| **`'callables'`** | Callables & Closures | `callable`, `Closure`, `callable(int): string`, `static-closure` | +| **`'properties'`** | Class Property Writes | `$this->id = 1`, `UserProfile::$username = 'Alice'` | -### Respect Ignore Tags (`respect_ignore_tags`) -Developers can bypass runtime checks on performance-critical loops or legacy methods by adding the `@typephp-ignore` tag to a docblock. -By default (`true`), TypePHP honors these tags and skips checking those specific methods. +### Important Notes on `inline_vars` Behavior -However, if you set this to `false`, TypePHP will **ignore the ignore tags** and enforce type-checking universally. This is incredibly useful for **CI/CD pipelines** or comprehensive test suites where you want to verify total type safety across the entire application without developers' local optimizations bypassing the tests. - -### Function Boundary Contracts (`params` & `returns`) -These options dictate whether TypePHP enforces the types defined in your method signatures and docblocks. -* `params`: Validates incoming arguments against `@param` tags. -* `returns`: Validates outbound data against `@return` tags. - -> **Why is there no fine-grained configuration for boundaries?** -> You might notice that `inline_vars` allows you to selectively disable specific type checks (like scalars or generics), but `params` and `returns` do not. **This is an intentional architectural decision.** -> -> Function boundaries represent the **public contract** of your application. If a method claims to return an `array`, that contract must be absolute. Allowing selective enforcement at boundaries (e.g., checking the `User` object but ignoring the `int` key) creates an unreliable, unpredictable API. -> -> Inline variables, on the other hand, represent **internal state**. We provide fine-grained controls for inline variables so developers can optimize internal loop performance (e.g., turning off heavy generic checks locally) without breaking the guarantees of the public API boundaries. - -### Caching (`cache`) -When enabled, TypePHP stores the transformed, type-injected versions of your PHP files on disk. Subsequent executions bypass the AST parsing phase entirely, resulting in near-native PHP execution speeds. - -### Extensions (`extensions`) -This array allows you to register custom type handlers or third-party TypePHP plugins. Provide the fully qualified class name (FQCN) of your extension to have it booted during TypePHP's initialization. - -### Inline Variable Validation (`inline_vars`) -Unlike boundaries, local variable assignments (using `@var` docblocks) offer granular control. You can toggle specific types of runtime checks on or off. For instance, you may want to ensure `objects` are strictly typed but disable `generics` checks if you are iterating over massive arrays and need to squeeze out extra micro-optimizations. +* **Inner Structural Types Are Always Validated:** Disabling `'scalars' => false` only turns off standalone scalar assignments (such as `/** @var positive-int $x */`). If `'arrays'` or `'generics'` is enabled, TypePHP **will still validate inner scalar constraints** inside array shapes (`array{id: positive-int}`), lists (`list`), or generic containers (`Collection`) to maintain structural type integrity. +* **Active Generic Instance Prebinding:** Enabling `'generics' => true` allows inline `@var` annotations on object instantiations (such as `/** @var Collection $users */ $users = new Collection();`) to **actively prebind generic template parameters (`T = User`)** directly to that object instance in `WeakMap` memory. Every subsequent method call on that instance (`$users->add()`, `$users->get()`) will enforce `T = User`! --- -## Path Resolution & Specificity Rules +## Pattern Specificity Rules -The `include` and `exclude` arrays determine which files TypePHP should analyze. If a file matches both an `include` rule and an `exclude` rule, TypePHP resolves the conflict by comparing the character length of the patterns: +If a file matches both an `include` rule and an `exclude` rule, TypePHP compares pattern lengths: -* **Specific Whitelist Wins:** `'vendor/my-org/package/**'` (length 25) takes precedence over `'vendor/**'` (length 8). This allows you to exclude an entire directory but whitelist a specific package inside it. +* **Specific Whitelist Wins:** `'vendor/my-org/package/**'` (length 25) takes precedence over `'vendor/**'` (length 8). * **Single File Override:** `'src/LegacyFile.php'` (length 22) takes precedence over `'src/**'` (length 6). -* **Tie-Breaker:** If pattern lengths are exactly equal, `exclude` takes precedence by default to ensure application safety and prevent unintended parsing errors. \ No newline at end of file +* **Tie-Breaker:** If pattern lengths are equal, `exclude` takes precedence to ensure application safety. diff --git a/docs/supported-types/unions-intersections-and-conditionals.md b/docs/supported-types/unions-intersections-and-conditionals.md new file mode 100644 index 0000000..c2ba9be --- /dev/null +++ b/docs/supported-types/unions-intersections-and-conditionals.md @@ -0,0 +1,241 @@ +# Unions, Intersections, Variadics, and Conditionals + +TypePHP provides rich runtime enforcement for complex type algebra, including Union (`|`) types, Intersection (`&`) types, Variadic (`...$items`) parameters, and Conditional Return Types. + +--- + +## Union Types (`A | B`) + +Union types specify that a value must satisfy **at least one** of the declared type variants. TypePHP evaluates union variants sequentially from left to right. + +### Scalar and Literal Value Unions + +Combine scalar types, refinements, and literal string/integer values: + +```php + 'success', + 'code' => 200, + 'data' => ['id' => 42], +]); + +// Valid Error Payload +handleApiResponse(false, [ + 'status' => 'error', + 'code' => 500, + 'message' => 'Internal Server Error', +]); + +// Invalid Payload (Missing required 'data' key for success variant) +handleApiResponse(true, [ + 'status' => 'success', + 'code' => 200, +]); +// Throws: TypeError: handleApiResponse(): Return value fails all union shape variants +``` + +--- + +## Intersection Types (`A & B`) + +Intersection types require an object or value to satisfy **all** declared interface or shape contracts simultaneously. + +> **Important Syntax Rules for Intersections:** +> 1. **Parentheses Requirement:** When combining intersections with unions or generic parameters (such as `(Countable & ArrayAccess) | (Iterator & Countable)` or `Collection`), always enclose the intersection in parentheses. +> 2. **No Raw Unions Inside Intersections:** PHPDoc syntax rules require Disjunctive Normal Form (Unions of Intersections). Placing raw unions directly inside an intersection (such as `A & (B | C)`) is unsupported and will be ignored. Always expand and write it as a union of intersections: `(A & B) | (A & C)`. + +### Interface Intersections + +Enforce that an object implements multiple interfaces: + +```php +/** + * @param Countable&ArrayAccess $collection + */ +function processCollection(object $collection): void +{ + // ... +} + +// Valid Call (Implements both Countable and ArrayAccess) +processCollection(new ArrayObject([1, 2, 3])); + +// Invalid Call (Implements Countable only) +class CountableOnly implements Countable { public function count(): int { return 0; } } +processCollection(new CountableOnly()); +// Throws: TypeError: processCollection(): Argument $collection must be of type Countable&ArrayAccess +``` + +### Unions of Intersections (Disjunctive Normal Form) + +TypePHP supports complex parenthesized unions of intersections: + +```php +/** + * Enclose each intersection member in parentheses + * + * @param (Countable&ArrayAccess)|(Iterator&Countable) $payload + */ +function processPayload(object $payload): void +{ + // ... +} + +// Valid Calls +processPayload(new ArrayObject([1, 2])); // Satisfies Countable & ArrayAccess +processPayload(new ArrayIterator([1, 2])); // Satisfies Iterator & Countable +``` + +--- + +## Variadic Parameter Contracts (`...$items`) + +When a function parameter uses PHP's variadic syntax (`...$items`), TypePHP validates **every individual argument** passed in the variadic argument list. + +### Scalar Variadics + +```php +/** + * @param positive-int ...$ids + */ +function deleteBatch(int ...$ids): void +{ + // ... +} + +// Valid Call +deleteBatch(10, 20, 30); + +// Invalid Call (3rd item violates positive-int) +deleteBatch(10, 20, -5); +// Throws: TypeError: deleteBatch(): Argument $ids[2] must be of type positive-int +``` + +### Variadic Unions and Shapes + +Combine variadic parameters with unions or array shapes: + +```php +/** + * Variadic Union + * + * @param (Dog|Cat) ...$animals + */ +function processAnimals(Animal ...$animals): void {} + +// Variadic Array Shapes +/** + * @param array{id: positive-int, username: non-empty-string} ...$users + */ +function processUsers(array ...$users): void {} + +processUsers( + ['id' => 1, 'username' => 'Alice'], + ['id' => 2, 'username' => 'Bob'] +); // Valid + +processUsers( + ['id' => 1, 'username' => 'Alice'], + ['id' => -5, 'username' => 'Bob'] // Invalid: id is negative +); +// Throws: TypeError: processUsers(): Argument $users[1]['id'] must be of type positive-int +``` + +--- + +## Conditional Return Types + +Conditional return types dynamically select the function's return contract based on incoming parameter values or bound template types. + +### Parameter-Based Conditional Return Types + +Use `@return ($param is TargetType ? ReturnA : ReturnB)` to evaluate return contracts based on a parameter's value: + +```php +/** + * @param bool $asInt + * @param mixed $value + * @return ($asInt is true ? positive-int : non-empty-string) + */ +function formatValue(bool $asInt, mixed $value): mixed +{ + return $value; +} + +// Evaluates return contract as positive-int +formatValue(true, 42); // Valid + +formatValue(true, 'not_an_int'); +// Throws: TypeError: formatValue(): Return value must be of type positive-int + +// Evaluates return contract as non-empty-string +formatValue(false, 'hello'); // Valid + +formatValue(false, ''); +// Throws: TypeError: formatValue(): Return value must be of type non-empty-string +``` + +### Template-Based Conditional Return Types + +Use `@return (T is TargetType ? ReturnA : ReturnB)` to evaluate return contracts based on an inferred template parameter `T`: + +```php +/** + * @template T + * + * @param T $input + * @param mixed $value + * @return (T is string ? positive-int : bool) + */ +function evaluateByTemplate(mixed $input, mixed $value): mixed +{ + return $value; +} + +// T is inferred as string -> Return contract becomes positive-int +evaluateByTemplate('input_string', 100); // Valid + +evaluateByTemplate('input_string', 'invalid_return'); +// Throws: TypeError: evaluateByTemplate(): Return value must be of type positive-int + +// T is inferred as int -> Return contract becomes bool +evaluateByTemplate(12345, true); // Valid + +evaluateByTemplate(12345, 'not_a_bool'); +// Throws: TypeError: evaluateByTemplate(): Return value must be of type bool +``` From 6ff1e94a417304ef4d1184e363e4cfebcbb5fc49 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 7 Aug 2026 03:37:57 +0800 Subject: [PATCH 04/15] Enhance Quick Start Guide with detailed recommendations for using TypePHP alongside static analyzers and clarify parameter validation examples. --- docs/getting-started/quick-start.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index 89ee3a8..b9d492a 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -1,6 +1,13 @@ # Quick Start Guide -TypePHP enforces PHPDoc type contracts at runtime. Below is a tour of core features and code examples. +TypePHP enforces PHPDoc type contracts at runtime. Below is an overview of core features and code examples. + +> **Recommended Workflow: PHPStan / Psalm / Mago / Phan + TypePHP** +> By design, TypePHP is a **runtime type enforcer, not a docblock linter or static analyzer**. For maximum execution performance, TypePHP gracefully ignores malformed docblock syntax and duplicate type alias declarations, focusing strictly on validating runtime data. +> +> It is highly recommended to use any static analyzer alongside TypePHP: +> * **PHPStan / Psalm / Mago / Phan (Compile-Time):** Lints your PHPDoc syntax, validates complex intersection rules, and catches static type errors in your IDE before code executes. +> * **TypePHP (Runtime):** Enforces those PHPDoc contracts during actual execution, ensuring your application against invalid API payloads, database records, and dynamic runtime data or make sure the doctypes will not lie to you at runtime, this is the case when you dont use static analyzers or only allow linient level on static type checking. --- @@ -55,7 +62,7 @@ TypePHP validates function return values before they are returned to the caller: function fetchUserData(int $id): array { if ($id <= 0) { - return ['id' => $id, 'status' => 'active']; // Invalid: $id is not positive-int + return ['id' => $id, 'status' => 'active']; // Invalid: $id is negative } return ['id' => $id, 'status' => 'active']; @@ -172,9 +179,7 @@ function legacyProcess(int $id): void legacyProcess(-500); // Passes without error ``` -Here is the updated section addressing the reader directly as **"you"**: - -### File-Level Suppression (`@typephp-ignore-file`) +### File-Level Suppression Place `@typephp-ignore-file` in a file-level docblock at the top of a file: @@ -193,4 +198,4 @@ namespace App\Legacy; ``` > **Technical Note & Coding Convention:** -> Under the hood, TypePHP scans the raw file contents for `@typephp-ignore-file` before performing AST transformations, meaning the tag will function regardless of its position in the file. However, you should always place `@typephp-ignore-file` at the very top of the file (right after ` Under the hood, TypePHP scans the raw file contents for `@typephp-ignore-file` before performing AST transformations, meaning the tag will function regardless of its position in the file. However, you should always place `@typephp-ignore-file` at the very top of the file (right after ` Date: Fri, 7 Aug 2026 17:19:28 +0800 Subject: [PATCH 05/15] Add clone instance generics support. Add comprehensive documentation for generics, cloning behavior, and type checking - Introduced detailed sections on reified generics and cloning generic instances in the documentation. - Added new test cases to validate generic template binding preservation during cloning. - Implemented methods in TypePHP for retrieving bound generic types and templates. - Removed obsolete test file and added new test files for generic classes. --- docs/core-concepts/generics-and-bounds.md | 142 +++++++++++++++++- internals/test-arrays.php | 3 - internals/test-clone-generics.php | 87 +++++++++++ internals/test-reified.php | 114 ++++++++++++++ src/Internal/ContractVisitor.php | 35 ++++- src/Internal/RuntimeTypeChecker.php | 30 +++- src/Resolver/TemplateManager.php | 108 ++++++++++--- src/TypePHP.php | 43 +++++- tests/Fixtures/Generics/GenericBox.php | 24 +++ .../Generics/GenericBoxWithMagicClone.php | 31 ++++ .../TypeChecking/CloneGenericInstanceTest.php | 73 +++++++++ tests/Unit/TypePHPTest.php | 22 +++ 12 files changed, 676 insertions(+), 36 deletions(-) delete mode 100644 internals/test-arrays.php create mode 100644 internals/test-clone-generics.php create mode 100644 internals/test-reified.php create mode 100644 tests/Fixtures/Generics/GenericBox.php create mode 100644 tests/Fixtures/Generics/GenericBoxWithMagicClone.php create mode 100644 tests/TypeChecking/CloneGenericInstanceTest.php diff --git a/docs/core-concepts/generics-and-bounds.md b/docs/core-concepts/generics-and-bounds.md index 827714e..64290c8 100644 --- a/docs/core-concepts/generics-and-bounds.md +++ b/docs/core-concepts/generics-and-bounds.md @@ -99,7 +99,143 @@ pairUp(new Car(), new Dog()); // Throws: TypeError: pairUp(): Argument $animal (template T) must be an instance of Animal, Car given ``` -Here is the new **Generics of Scalars, Refinements, and Array Shapes** section for `docs/core-concepts/generics-and-bounds.md`: +--- + +## Reified Generics API (`TypePHP::getGenericType`) + +Unlike languages that use Type Erasure (such as TypeScript or Java), TypePHP maintains generic template parameters in memory. + +You can inspect an object's bound generic types at runtime using `TypePHP::getGenericType()` or `TypePHP::getGenericTypes()`: + +```php +use TypePHP\TypePHP; + +/** @var Collection $users */ +$users = new Collection(); + +/** @var Dictionary $catalog */ +$catalog = new Dictionary(); + +// 1. Single-Template Smart Fallback (No template name needed!) +$userType = TypePHP::getGenericType($users); // Returns 'App\Models\User' + +// 2. Multi-Template Explicit Inspection +$keyType = TypePHP::getGenericType($catalog, 'K'); // Returns 'string' +$valueType = TypePHP::getGenericType($catalog, 'V'); // Returns 'App\Models\Product' + +// 3. Inherited Generic Classes (@extends BaseRepository) +$userRepo = new UserRepository(); +$repoType = TypePHP::getGenericType($userRepo); // Returns 'App\Models\User' + +// 4. Inspect all bound template parameters as an array +$types = TypePHP::getGenericTypes($catalog); // Returns ['K' => 'string', 'V' => 'App\Models\Product'] +``` + +### How Reified Generic Inspection Works + +* **Single-Template Smart Fallback:** If a class has only 1 template parameter (e.g. `@template ItemType`), `TypePHP::getGenericType($object)` automatically returns that template's bound type without requiring you to guess whether the author named it `T`, `E`, or `ItemType`. +* **Inherited Template Resolution:** Automatically resolves generic types declared on parent classes (`@extends BaseRepository`) or interfaces (`@implements ProcessorInterface`). +* **First-Use Inference:** On un-annotated generic instances (`$collection = new Collection()`), `getGenericType()` returns `null` before first use, and returns the inferred type (e.g. `User`) immediately after the first method call! +``` + +--- + +## Cloning Generic Instances (`clone $obj` and `__clone()`) + +When you clone an object instance that has bound generic templates (`$cloned = clone $original`), TypePHP automatically preserves and copies all bound generic template parameters (`T`) to the new cloned object instance in `WeakMap` memory: + +```php +/** @var Collection $users */ +$users = new Collection(); +$users->add(new User('Alice')); + +// Clone the generic collection +$clonedUsers = clone $users; + +// The cloned collection retains T = User! +$clonedUsers->add(new User('Bob')); // Valid + +$clonedUsers->add(new Product('SKU-100')); +// Throws: TypeError: Collection::add(): Argument $item (template T = User) must be of type User +``` + +### Explicit `__clone()` Magic Methods + +If a generic class defines an explicit `__clone()` magic method, TypePHP copies the generic template bindings to the new object instance **before** the `__clone()` method body executes. + +This ensures that any property assignments or method calls inside your `__clone()` implementation are immediately protected by the bound generic types: + +```php +/** + * @template T + */ +class GenericBox +{ + /** @var T */ + public mixed $item = null; + + /** @param T $item */ + public function set(mixed $item): void + { + $this->item = $item; + } + + public function __clone(): void + { + // TypePHP pre-copies T = Dog before __clone() runs! + $this->item = new Dog(); // Valid (Dog satisfies T = Dog) + } +} + +/** @var GenericBox $box */ +$box = new GenericBox(); +$clonedBox = clone $box; + +$clonedBox->set(new Car()); +// Throws: TypeError: GenericBox::set(): Argument $item (template T = Dog) must be of type Dog +``` + +### Cloned Instance Memory Isolation (`WeakMap`) + +When an object is cloned, its generic bindings are copied by value to the new instance. Because TypePHP uses `\WeakMap` keyed by object instance ID, **the original and cloned instances are 100% isolated in memory**. + +Re-binding or modifying the generic type on a cloned instance will never affect the original instance: + +```php +/** @var GenericBox $box1 */ +$box1 = new GenericBox(); + +// Clone $box1 into $box2 +$box2 = clone $box1; + +// Re-bind $box2 instance to GenericBox +/** @var GenericBox $box2 */ + +// $box2 now accepts Cat +$box2->set(new Cat()); // Valid for $box2 + +// $box1 continues enforcing T = Dog and rejects Cat! +$box1->set(new Cat()); +// Throws: TypeError: GenericBox::set(): Argument $item (template T = Dog) must be of type Dog +``` + +### Variance Enforcement on Cloned Assignments + +When you assign a cloned generic instance to a variable with an inline `@var` annotation, TypePHP enforces generic variance rules during the assignment. + +Because generics are **invariant** by default, assigning a cloned `GenericBox` instance into a variable annotated as `GenericBox` throws an invariant type mismatch `TypeError`: + +```php +/** @var GenericBox $box1 */ +$box1 = new GenericBox(); + +// Assigning a GenericBox clone into a GenericBox variable +/** @var GenericBox $box2 */ +$box2 = clone $box1; +// Throws: TypeError: Variable $box2 expects GenericBox, but GenericBox was given +``` + +> **Deep Dive Guide:** For complete details on how covariance, contravariance, and invariance rules work across generic containers, see the [Demystifying Variance](#demystifying-variance-covariant-contravariant-invariant) section below. --- @@ -536,7 +672,7 @@ checkBox(new Box(new Dog())); // Invalid in invariant mode! Covariance allows **subtypes** (`Dog` for `Animal`). Think of covariance as a **Producer / Read-Only** relationship. -If a function only *reads* from a container producing `Animal`s, passing a container producing `Dog`s is completely safe—because every `Dog` read out of the container is guaranteed to be an `Animal`! +If a function only *reads* from a container producing `Animal`s, passing a container producing `Dog`s is completely safe because every `Dog` read out of the container is guaranteed to be an `Animal`! ```php /** @@ -571,7 +707,7 @@ handleProducer(new Producer(new Car())); Contravariance allows **supertypes** (`Animal` for `Dog`). Think of contravariance as a **Consumer / Write-Only** relationship. -If a function needs a handler that consumes a `Dog`, giving it a handler that can consume any `Animal` is completely safe—because an `Animal` handler can process any `Dog` given to it! +If a function needs a handler that consumes a `Dog`, giving it a handler that can consume any `Animal` is completely safe because an `Animal` handler can process any `Dog` given to it! ```php class Puppy extends Dog {} diff --git a/internals/test-arrays.php b/internals/test-arrays.php deleted file mode 100644 index 174d7fd..0000000 --- a/internals/test-arrays.php +++ /dev/null @@ -1,3 +0,0 @@ -item = $item; + } +} + +/** + * 2. Generic Class with Explicit __clone() Magic Method + * + * @template T + */ +class GenericBoxWithMagicClone +{ + /** + * @var T + */ + public mixed $item = null; + + /** + * @param T $item + */ + public function set(mixed $item): void + { + $this->item = $item; + } + + public function __clone(): void + { + // Assign a valid Dog instance inside __clone() so it satisfies @var T (where T = Dog) + $this->item = new Dog(); + } +} + +echo "=== Testing Clone Keyword & Generic Prebinding Preservation ===\n\n"; + +// TEST 1: Standard Class (No __clone) +echo "1. Standard Class (No __clone()):\n"; +/** @var GenericBox $dogBox */ +$dogBox = new GenericBox(); + +$clonedBox = clone $dogBox; + +try { + $clonedBox->set(new Car()); + echo " ❌ FAIL: Standard cloned box accepted Car! T = Dog was lost!\n"; +} catch (TypeError $e) { + echo " ✅ SUCCESS: Caught expected TypeError!\n"; + echo " Message: " . $e->getMessage() . "\n\n"; +} + +// TEST 2: Class with Magic __clone() +echo "2. Class with Explicit Magic __clone():\n"; +/** @var GenericBoxWithMagicClone $magicBox */ +$magicBox = new GenericBoxWithMagicClone(); + +$clonedMagicBox = clone $magicBox; + +try { + $clonedMagicBox->set(new Car()); + echo " ❌ FAIL: Cloned box with __clone() accepted Car! T = Dog was lost!\n"; +} catch (TypeError $e) { + echo " ✅ SUCCESS: Caught expected TypeError!\n"; + echo " Message: " . $e->getMessage() . "\n"; +} \ No newline at end of file diff --git a/internals/test-reified.php b/internals/test-reified.php new file mode 100644 index 0000000..b04d57e --- /dev/null +++ b/internals/test-reified.php @@ -0,0 +1,114 @@ + */ + public array $items = []; + + /** @param T $item */ + public function add(mixed $item): void { $this->items[] = $item; } +} + +/** + * 2. Custom Named Single Template (@template ItemType) + * + * @template ItemType + */ +class Box +{ + /** @var ItemType */ + public mixed $item = null; +} + +/** + * 3. Multiple Templates (@template K, @template V) + * + * @template K + * @template V + */ +class Dictionary +{ + /** @var array */ + public array $map = []; +} + +/** + * 4. Inherited Generic Class (@extends) + * + * @template T + */ +abstract class BaseRepository +{ + /** @param T $entity */ + public function save(mixed $entity): void {} +} + +/** + * @extends BaseRepository + */ +class UserRepository extends BaseRepository {} + +echo "=== Testing Reified Generics API (TypePHP::getGenericType) ===\n\n"; + +// Scenario 1: Standard Single Template (Collection vs Collection) +echo "1. Standard Single Template (@template T):\n"; +/** @var Collection $users */ +$users = new Collection(); + +/** @var Collection $products */ +$products = new Collection(); + +echo " User Collection T: " . TypePHP::getGenericType($users) . "\n"; +echo " Product Collection T: " . TypePHP::getGenericType($products) . "\n"; +echo " All Types Array: " . json_encode(TypePHP::getGenericTypes($users)) . "\n\n"; + +// Scenario 2: Custom Template Parameter Name (@template ItemType) +echo "2. Custom Template Parameter Name (@template ItemType):\n"; +/** @var Box $orderBox */ +$orderBox = new Box(); + +echo " Smart Fallback Type: " . TypePHP::getGenericType($orderBox) . "\n"; +echo " Explicit 'ItemType': " . TypePHP::getGenericType($orderBox, 'ItemType') . "\n"; +echo " All Types Array: " . json_encode(TypePHP::getGenericTypes($orderBox)) . "\n\n"; + +// Scenario 3: Multiple Template Parameters (@template K, @template V) +echo "3. Multiple Template Parameters (@template K, @template V):\n"; +/** @var Dictionary $catalog */ +$catalog = new Dictionary(); + +echo " Key Template K: " . TypePHP::getGenericType($catalog, 'K') . "\n"; +echo " Value Template V: " . TypePHP::getGenericType($catalog, 'V') . "\n"; +echo " All Types Array: " . json_encode(TypePHP::getGenericTypes($catalog)) . "\n\n"; + +// Scenario 4: Inherited Generics via @extends +echo "4. Inherited Generic Class (@extends BaseRepository):\n"; +$userRepo = new UserRepository(); + +echo " Inherited Repo T: " . TypePHP::getGenericType($userRepo) . "\n"; +echo " All Types Array: " . json_encode(TypePHP::getGenericTypes($userRepo)) . "\n\n"; + +// Scenario 5: Unannotated Generic Instance (Before First Use) +echo "5. Unannotated Generic Instance (Before First Use):\n"; +$mystery = new Collection(); + +echo " Unbound Type: " . (TypePHP::getGenericType($mystery) ?? 'null') . "\n"; +echo " All Types Array: " . json_encode(TypePHP::getGenericTypes($mystery)) . "\n\n"; + +// Scenario 6: First-Use Type Inference +echo "6. First-Use Type Inference (After First Method Call):\n"; +$mystery->add(new User('Bob')); // First method call infers T = User! +echo " Inferred Type T: " . TypePHP::getGenericType($mystery) . "\n"; +echo " All Types Array: " . json_encode(TypePHP::getGenericTypes($mystery)) . "\n"; \ No newline at end of file diff --git a/src/Internal/ContractVisitor.php b/src/Internal/ContractVisitor.php index 28f14ae..5489010 100644 --- a/src/Internal/ContractVisitor.php +++ b/src/Internal/ContractVisitor.php @@ -100,7 +100,7 @@ public function enterNode(Node $node): int|array|null } if ($node instanceof Node\Expr\Assign) { - if ($node->var instanceof Node\Expr\Variable && \is_string($node->var->name)) { + if ($node->var instanceof Node\Expr\Variable && is_string($node->var->name)) { $varName = $node->var->name; $typeString = $this->scopeManager->getVarTypeFromScope($varName); @@ -118,8 +118,8 @@ public function enterNode(Node $node): int|array|null $propName = $node->var->name->toString(); $classExpr = $node->var->class; - $classArg = $classExpr instanceof Node\Name - ? new Node\Expr\ClassConstFetch($classExpr, 'class') + $classArg = $classExpr instanceof Node\Name + ? new Node\Expr\ClassConstFetch($classExpr, 'class') : $classExpr; $checkCall = NodeBuilder::createPropertyCheckCall($node->expr, $classArg, $propName); @@ -131,10 +131,31 @@ public function enterNode(Node $node): int|array|null } /** - * Pops the current lexical scope stack frame when leaving a function, method, closure, or control block. + * Pops the current lexical scope stack frame or replaces transformed expressions upon leaving a node. */ - public function leaveNode(Node $node): ?int + public function leaveNode(Node $node): Node|int|array|null { + if ($node instanceof Node\Expr\Clone_) { + if ($node->getAttribute('typephp_wrapped') === true) { + return null; + } + + $node->setAttribute('typephp_wrapped', true); + + return new Node\Expr\FuncCall( + new Node\Name('\TypePHP\Internal\RuntimeTypeChecker::cloneInstance'), + [ + new Node\Expr\Clone_( + new Node\Expr\FuncCall( + new Node\Name('\TypePHP\Internal\RuntimeTypeChecker::prepareClone'), + [new Node\Arg($node->expr)] + ) + ), + new Node\Arg($node->expr), + ] + ); + } + if ($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod || $node instanceof Node\Expr\Closure @@ -167,7 +188,7 @@ private function extractDestructuringVariables(Node\Expr\List_|Node\Expr\Array_ continue; } - if ($item->value instanceof Node\Expr\Variable && \is_string($item->value->name)) { + if ($item->value instanceof Node\Expr\Variable && is_string($item->value->name)) { $vars[] = [ 'varName' => $item->value->name, 'expr' => $item->value, @@ -179,4 +200,4 @@ private function extractDestructuringVariables(Node\Expr\List_|Node\Expr\Array_ return $vars; } -} +} \ No newline at end of file diff --git a/src/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index cc449ba..ecef905 100644 --- a/src/Internal/RuntimeTypeChecker.php +++ b/src/Internal/RuntimeTypeChecker.php @@ -16,7 +16,7 @@ use TypePHP\Wrapper\IterableWrapper; /** - * @internal Core runtime type checking engine facade for parameter validation, return type enforcement, and variable tracking. + * Core runtime type checking engine facade for parameter validation, return type enforcement, and variable tracking. */ final class RuntimeTypeChecker { @@ -166,6 +166,32 @@ public static function wrapIterable(string $function, string $paramName, mixed $ return IterableWrapper::wrap($function, $paramName, $iterable, self::getRegistry()); } + /** + * Registers a pending clone source object before clone execution. + */ + public static function prepareClone(mixed $original): mixed + { + if (is_object($original)) { + TemplateManager::$pendingCloneSource = $original; + } + + return $original; + } + + /** + * Copies generic template bindings from an original object to a cloned object instance. + */ + public static function cloneInstance(mixed $cloned, mixed $original): mixed + { + if (is_object($cloned) && is_object($original)) { + TemplateManager::copyInstanceBindings($original, $cloned); + } + + TemplateManager::$pendingCloneSource = null; + + return $cloned; + } + /** * Infers a TypeNode AST representation from a raw PHP value. */ @@ -181,4 +207,4 @@ public static function getRegistry(): TypeValidatorRegistry { return self::$registry ??= new TypeValidatorRegistry(); } -} +} \ No newline at end of file diff --git a/src/Resolver/TemplateManager.php b/src/Resolver/TemplateManager.php index 1e68ead..896772e 100644 --- a/src/Resolver/TemplateManager.php +++ b/src/Resolver/TemplateManager.php @@ -41,6 +41,22 @@ final class TemplateManager */ private static array $callStackBindings = []; + /** + * Temporary storage for an original object instance being cloned. + */ + public static ?object $pendingCloneSource = null; + + /** + * Copies bound generic template types from a source object to a cloned target object. + */ + public static function copyInstanceBindings(object $source, object $target): void + { + if (self::$instanceTemplateBindings !== null && isset(self::$instanceTemplateBindings[$source])) { + $bindings = self::$instanceTemplateBindings[$source]; + self::$instanceTemplateBindings[$target] = $bindings; + } + } + /** * Pushes a new empty call frame onto the stack for a function execution. */ @@ -71,6 +87,7 @@ public static function clearCallBindings(string $function, array $templates): vo /** * Retrieves currently bound template types for a function call or object instance. + * Automatically resolves pending clone sources. * * @param array $templates * @@ -78,8 +95,18 @@ public static function clearCallBindings(string $function, array $templates): vo */ public static function getBoundTemplates(string $function, ?object $thisObj, array $templates): array { - if ($thisObj !== null && isset(self::$instanceTemplateBindings[$thisObj])) { - return self::$instanceTemplateBindings[$thisObj]; + if ($thisObj !== null) { + if (self::$pendingCloneSource !== null && ! isset(self::$instanceTemplateBindings[$thisObj])) { + self::copyInstanceBindings(self::$pendingCloneSource, $thisObj); + } + + if (self::$instanceTemplateBindings === null || ! isset(self::$instanceTemplateBindings[$thisObj])) { + self::resolveInheritedTemplates($thisObj, get_class($thisObj)); + } + + if (isset(self::$instanceTemplateBindings[$thisObj])) { + return self::$instanceTemplateBindings[$thisObj]; + } } if (self::hasCallFrame($function)) { @@ -91,12 +118,44 @@ public static function getBoundTemplates(string $function, ?object $thisObj, arr return []; } + /** + * Retrieves all bound template TypeNodes for a specific object instance. + * Automatically resolves @extends and @implements template mappings if unbound. + * + * @return array + */ + public static function getBoundTemplatesForInstance(object $instance): array + { + if (self::$pendingCloneSource !== null && ! isset(self::$instanceTemplateBindings[$instance])) { + self::copyInstanceBindings(self::$pendingCloneSource, $instance); + } + + // Auto-resolve @extends and @implements generic template mappings + if (self::$instanceTemplateBindings === null || ! isset(self::$instanceTemplateBindings[$instance])) { + self::resolveInheritedTemplates($instance, get_class($instance)); + } + + if (self::$instanceTemplateBindings !== null && isset(self::$instanceTemplateBindings[$instance])) { + return self::$instanceTemplateBindings[$instance]; + } + + return []; + } + /** * Checks if a template name is bound in the current instance or call stack frame. */ public static function isBound(string $function, ?object $thisObj, string $templateName): bool { if ($thisObj !== null) { + if (self::$pendingCloneSource !== null && ! isset(self::$instanceTemplateBindings[$thisObj])) { + self::copyInstanceBindings(self::$pendingCloneSource, $thisObj); + } + + if (self::$instanceTemplateBindings === null || ! isset(self::$instanceTemplateBindings[$thisObj])) { + self::resolveInheritedTemplates($thisObj, get_class($thisObj)); + } + return isset(self::$instanceTemplateBindings[$thisObj][$templateName]); } @@ -115,6 +174,14 @@ public static function isBound(string $function, ?object $thisObj, string $templ public static function getBoundType(string $function, ?object $thisObj, string $templateName): ?TypeNode { if ($thisObj !== null) { + if (self::$pendingCloneSource !== null && ! isset(self::$instanceTemplateBindings[$thisObj])) { + self::copyInstanceBindings(self::$pendingCloneSource, $thisObj); + } + + if (self::$instanceTemplateBindings === null || ! isset(self::$instanceTemplateBindings[$thisObj])) { + self::resolveInheritedTemplates($thisObj, get_class($thisObj)); + } + return self::$instanceTemplateBindings[$thisObj][$templateName] ?? null; } @@ -143,7 +210,7 @@ public static function bindTemplate(string $function, ?object $thisObj, string $ if (! self::hasCallFrame($function)) { self::$callStackBindings[$function][] = []; } - $lastIndex = \count(self::$callStackBindings[$function]) - 1; + $lastIndex = count(self::$callStackBindings[$function]) - 1; self::$callStackBindings[$function][$lastIndex][$templateName] = $inferredType; } } @@ -154,8 +221,8 @@ public static function bindTemplate(string $function, ?object $thisObj, string $ public static function bindInstanceFromNode(object $instance, GenericTypeNode $typeNode, string $context = '', bool $forceBind = false): ?ErrorMessage { $className = $typeNode->type->name; - if (\in_array(strtolower($className), ['self', 'static', '$this'], true)) { - $className = \get_class($instance); + if (in_array(strtolower($className), ['self', 'static', '$this'], true)) { + $className = get_class($instance); } if (! is_a($instance, $className)) { @@ -246,7 +313,7 @@ public static function bindInstanceFromNode(object $instance, GenericTypeNode $t */ public static function resolveInheritedTemplates(object $instance, string $targetClassName): void { - $actualClassName = \get_class($instance); + $actualClassName = get_class($instance); try { $ref = new \ReflectionClass($actualClassName); @@ -268,7 +335,8 @@ public static function resolveInheritedTemplates(object $instance, string $targe if ($genericTypeNode instanceof GenericTypeNode) { $parentName = SpecialTypeResolver::resolveFqcn($genericTypeNode->type->name, $ref); - if (ClassNameValidator::isValid($parentName) && ($parentName === $targetClassName || is_a($parentName, $targetClassName, true))) { + // Fixed inverted is_a check: Checks if actual class extends/implements parentName! + if (ClassNameValidator::isValid($parentName) && is_a($actualClassName, $parentName, true)) { if (! class_exists($parentName) && ! interface_exists($parentName)) { continue; } @@ -478,25 +546,25 @@ public static function bindInstance(object $instance, string $typeString, string */ public static function inferTypeFromValue(mixed $value): TypeNode { - if (\is_int($value)) { + if (is_int($value)) { return new IdentifierTypeNode('int'); } - if (\is_string($value)) { + if (is_string($value)) { return new IdentifierTypeNode('string'); } - if (\is_float($value)) { + if (is_float($value)) { return new IdentifierTypeNode('float'); } - if (\is_bool($value)) { + if (is_bool($value)) { return new IdentifierTypeNode('bool'); } - if (\is_array($value)) { + if (is_array($value)) { return new IdentifierTypeNode(array_is_list($value) ? 'list' : 'array'); } - if (\is_object($value)) { - $className = \get_class($value); - if (self::$instanceTemplateBindings !== null && isset(self::$instanceTemplateBindings[$value]) && \count(self::$instanceTemplateBindings[$value]) > 0) { + if (is_object($value)) { + $className = get_class($value); + if (self::$instanceTemplateBindings !== null && isset(self::$instanceTemplateBindings[$value]) && count(self::$instanceTemplateBindings[$value]) > 0) { $genericTypes = array_values(self::$instanceTemplateBindings[$value]); return new GenericTypeNode(new IdentifierTypeNode($className), $genericTypes); @@ -517,7 +585,7 @@ public static function inferTypeFromValue(mixed $value): TypeNode */ private static function hasCallFrame(string $function): bool { - return isset(self::$callStackBindings[$function]) && \count(self::$callStackBindings[$function]) > 0; + return isset(self::$callStackBindings[$function]) && count(self::$callStackBindings[$function]) > 0; } /** @@ -532,7 +600,7 @@ private static function resolveTypeNodeAst(TypeNode $n, \ReflectionClass $ref): } if ($n instanceof GenericTypeNode) { $base = new IdentifierTypeNode(SpecialTypeResolver::resolveFqcn($n->type->name, $ref)); - $generics = array_map(fn ($t) => self::resolveTypeNodeAst($t, $ref), $n->genericTypes); + $generics = array_map(fn($t) => self::resolveTypeNodeAst($t, $ref), $n->genericTypes); return new GenericTypeNode($base, $generics, $n->variances); } @@ -543,10 +611,10 @@ private static function resolveTypeNodeAst(TypeNode $n, \ReflectionClass $ref): return new NullableTypeNode(self::resolveTypeNodeAst($n->type, $ref)); } if ($n instanceof UnionTypeNode) { - return new UnionTypeNode(array_map(fn ($t) => self::resolveTypeNodeAst($t, $ref), $n->types)); + return new UnionTypeNode(array_map(fn($t) => self::resolveTypeNodeAst($t, $ref), $n->types)); } if ($n instanceof IntersectionTypeNode) { - return new IntersectionTypeNode(array_map(fn ($t) => self::resolveTypeNodeAst($t, $ref), $n->types)); + return new IntersectionTypeNode(array_map(fn($t) => self::resolveTypeNodeAst($t, $ref), $n->types)); } return $n; @@ -596,4 +664,4 @@ private static function getTypeParserComponents(): array return [$typeParser, $lexer]; } -} +} \ No newline at end of file diff --git a/src/TypePHP.php b/src/TypePHP.php index 90f6009..a2072ca 100644 --- a/src/TypePHP.php +++ b/src/TypePHP.php @@ -6,6 +6,7 @@ use TypePHP\Internal\Config; use TypePHP\Internal\StreamWrapper; +use TypePHP\Resolver\TemplateManager; final class TypePHP { @@ -17,6 +18,46 @@ public static function boot(): void StreamWrapper::register(Config::get()); } + /** + * Returns the bound generic type for a template parameter on an object instance (Reified Generics). + * If no template name is specified on a single-template class, returns the bound type automatically. + */ + public static function getGenericType(object $instance, ?string $templateName = null): ?string + { + $types = self::getGenericTypes($instance); + if (\count($types) === 0) { + return null; + } + + + if ($templateName !== null && isset($types[$templateName])) { + return $types[$templateName]; + } + + if (count($types) === 1) { + return reset($types); + } + + return $types['T'] ?? null; + } + + /** + * Returns all bound generic template parameters for an object instance as a key-value array. + * + * @return array + */ + public static function getGenericTypes(object $instance): array + { + $boundNodes = TemplateManager::getBoundTemplatesForInstance($instance); + $types = []; + + foreach ($boundNodes as $name => $node) { + $types[$name] = (string) $node; + } + + return $types; + } + /** * Returns the current resolved global configuration settings. * @@ -46,4 +87,4 @@ public static function resetConfig(): void { Config::reset(); } -} +} \ No newline at end of file diff --git a/tests/Fixtures/Generics/GenericBox.php b/tests/Fixtures/Generics/GenericBox.php new file mode 100644 index 0000000..6473a53 --- /dev/null +++ b/tests/Fixtures/Generics/GenericBox.php @@ -0,0 +1,24 @@ +item = $item; + } +} \ No newline at end of file diff --git a/tests/Fixtures/Generics/GenericBoxWithMagicClone.php b/tests/Fixtures/Generics/GenericBoxWithMagicClone.php new file mode 100644 index 0000000..bbcf6b4 --- /dev/null +++ b/tests/Fixtures/Generics/GenericBoxWithMagicClone.php @@ -0,0 +1,31 @@ +item = $item; + } + + public function __clone(): void + { + $this->item = new Dog(); + } +} \ No newline at end of file diff --git a/tests/TypeChecking/CloneGenericInstanceTest.php b/tests/TypeChecking/CloneGenericInstanceTest.php new file mode 100644 index 0000000..bc0ebe3 --- /dev/null +++ b/tests/TypeChecking/CloneGenericInstanceTest.php @@ -0,0 +1,73 @@ + $dogBox */ + $dogBox = new GenericBox(); + $dogBox->set(new Dog()); + + $clonedBox = clone $dogBox; + + $clonedBox->set(new Dog()); + expect($clonedBox->item)->toBeInstanceOf(Dog::class); + + expect(fn () => $clonedBox->set(new Car())) + ->toThrow(TypeError::class, 'template T = TypePHP\Tests\Fixtures\Domain\Dog'); + }); + + test('preserves generic template bindings when an object with __clone() is cloned', function () { + /** @var GenericBoxWithMagicClone $magicBox */ + $magicBox = new GenericBoxWithMagicClone(); + $clonedMagicBox = clone $magicBox; + + $clonedMagicBox->set(new Dog()); + expect($clonedMagicBox->item)->toBeInstanceOf(Dog::class); + + expect(fn () => $clonedMagicBox->set(new Car())) + ->toThrow(TypeError::class, 'template T = TypePHP\Tests\Fixtures\Domain\Dog'); + }); + + test('isolates generic template bindings and object state between original and cloned instances in WeakMap', function () { + /** @var GenericBox $box1 */ + $box1 = new GenericBox(); + $dog1 = new Dog(); + $box1->set($dog1); + + // Clone $box1 into $box2 + $box2 = clone $box1; + + // Mutate $box2's item to a new Dog instance + $dog2 = new Dog(); + $box2->set($dog2); + + // Verify WeakMap and property state isolation + expect($box2->item)->toBe($dog2) + ->and($box1->item)->toBe($dog1); // $box1's item remains unchanged! + + // Both $box1 and $box2 independently enforce T = Dog and reject Car! + expect(fn () => $box1->set(new Car())) + ->toThrow(TypeError::class, 'template T = TypePHP\Tests\Fixtures\Domain\Dog'); + + expect(fn () => $box2->set(new Car())) + ->toThrow(TypeError::class, 'template T = TypePHP\Tests\Fixtures\Domain\Dog'); + }); + + test('enforces invariant generic type matching when assigning a cloned instance to an incompatible variable annotation', function () { + /** @var GenericBox $box1 */ + $box1 = new GenericBox(); + $box1->set(new Dog()); + + expect(function () use ($box1) { + /** @var GenericBox $box2 */ + $box2 = clone $box1; + })->toThrow(TypeError::class, 'GenericBox'); + }); +}); \ No newline at end of file diff --git a/tests/Unit/TypePHPTest.php b/tests/Unit/TypePHPTest.php index 3f567d8..67227f3 100644 --- a/tests/Unit/TypePHPTest.php +++ b/tests/Unit/TypePHPTest.php @@ -4,8 +4,20 @@ namespace TypePHP\Tests\Unit; +use TypePHP\Tests\Fixtures\Domain\Cat; +use TypePHP\Tests\Fixtures\Domain\Dog; +use TypePHP\Tests\Fixtures\Generics\GenericCollection; use TypePHP\TypePHP; +/** + * @template ItemType + */ +class CustomTemplateNameBox +{ + /** @var ItemType */ + public mixed $item = null; +} + describe('TypePHP Public Facade Unit Tests', function () { afterEach(function () { TypePHP::resetConfig(); @@ -39,4 +51,14 @@ TypePHP::resetConfig(); expect(TypePHP::getConfig()['cache'])->toBeTrue(); }); + + test('inspects single template parameter automatically even if custom template name is used', function () { + /** @var CustomTemplateNameBox $box */ + $box = new CustomTemplateNameBox(); + + // Automatically inspects 'ItemType' without needing to guess the template name! + expect(TypePHP::getGenericType($box))->toBe(Dog::class) + ->and(TypePHP::getGenericType($box, 'ItemType'))->toBe(Dog::class) + ->and(TypePHP::getGenericTypes($box))->toBe(['ItemType' => Dog::class]); + }); }); From d1597b7180176c8774002c6f9fa65ef8237a8119 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 7 Aug 2026 19:30:05 +0800 Subject: [PATCH 06/15] Add comprehensive documentation for arrays, shapes, callables, and primitives; implement template variance retrieval in TypePHP --- docs/core-concepts/generics-and-bounds.md | 26 +- docs/supported-types/arrays-and-shapes.md | 286 ++++++++++++++++++ .../supported-types/callables-and-closures.md | 247 +++++++++++++++ .../supported-types/primitives-and-scalars.md | 280 +++++++++++++++++ src/Resolver/TemplateManager.php | 45 ++- src/TypePHP.php | 31 ++ src/Validator/IdentifierValidator.php | 6 +- tests/Unit/TypePHPTest.php | 65 +++- 8 files changed, 967 insertions(+), 19 deletions(-) create mode 100644 docs/supported-types/arrays-and-shapes.md create mode 100644 docs/supported-types/callables-and-closures.md create mode 100644 docs/supported-types/primitives-and-scalars.md diff --git a/docs/core-concepts/generics-and-bounds.md b/docs/core-concepts/generics-and-bounds.md index 64290c8..6405ca4 100644 --- a/docs/core-concepts/generics-and-bounds.md +++ b/docs/core-concepts/generics-and-bounds.md @@ -101,7 +101,7 @@ pairUp(new Car(), new Dog()); --- -## Reified Generics API (`TypePHP::getGenericType`) +## Reified Generics API (Kind of) Unlike languages that use Type Erasure (such as TypeScript or Java), TypePHP maintains generic template parameters in memory. @@ -116,19 +116,25 @@ $users = new Collection(); /** @var Dictionary $catalog */ $catalog = new Dictionary(); -// 1. Single-Template Smart Fallback (No template name needed!) -$userType = TypePHP::getGenericType($users); // Returns 'App\Models\User' +// Single-Template Smart Fallback (No template name needed!) +$userType = TypePHP::getGenericType(object: $users); // Returns 'App\Models\User' -// 2. Multi-Template Explicit Inspection -$keyType = TypePHP::getGenericType($catalog, 'K'); // Returns 'string' -$valueType = TypePHP::getGenericType($catalog, 'V'); // Returns 'App\Models\Product' +// Multi-Template Explicit Inspection +$keyType = TypePHP::getGenericType(object: $catalog, template: 'K'); // Returns 'string' +$valueType = TypePHP::getGenericType(object: $catalog, template: 'V'); // Returns 'App\Models\Product' -// 3. Inherited Generic Classes (@extends BaseRepository) +// Inherited Generic Classes (@extends BaseRepository) $userRepo = new UserRepository(); -$repoType = TypePHP::getGenericType($userRepo); // Returns 'App\Models\User' +$repoType = TypePHP::getGenericType(object: $userRepo); // Returns 'App\Models\User' -// 4. Inspect all bound template parameters as an array -$types = TypePHP::getGenericTypes($catalog); // Returns ['K' => 'string', 'V' => 'App\Models\Product'] +// Inspect all bound template parameters as an array +$types = TypePHP::getGenericTypes(object: $catalog); // Returns ['K' => 'string', 'V' => 'App\Models\Product'] + +// Inspect Declared Variance ('covariant', 'contravariant', or 'invariant') +$variance = TypePHP::getGenericVariance(object: $producer); // Returns 'covariant' + +// Inspect All Bound Variances as Arrays +$variances = TypePHP::getGenericVariances(object: $producer); // Returns ['T' => 'covariant'] ``` ### How Reified Generic Inspection Works diff --git a/docs/supported-types/arrays-and-shapes.md b/docs/supported-types/arrays-and-shapes.md new file mode 100644 index 0000000..457619d --- /dev/null +++ b/docs/supported-types/arrays-and-shapes.md @@ -0,0 +1,286 @@ +# Arrays & Shapes + +TypePHP provides runtime enforcement for sequential lists, key-value generic maps, typed class arrays, positional tuples, sealed and unsealed array shapes, and object shapes. + +--- + +## Sequential Lists (`list` & `non-empty-list`) + +A `list` represents a sequential, 0-indexed integer key array without gaps. TypePHP validates lists at runtime using PHP's native `array_is_list()` function: + +```php + $tags + * @param non-empty-list $scores + */ +function processList(array $tags, array $scores): void +{ + // ... +} + +// Valid Call +processList(['php', 'pest', 'typephp'], [10, 20, 30]); + +// Invalid Call (Associative array passed where list was expected) +processList(['tag1' => 'php'], [10, 20]); +// Throws: TypeError: processList(): Argument $tags must be a list + +// Invalid Call (Empty array passed where non-empty-list was expected) +processList(['php'], []); +// Throws: TypeError: processList(): Argument $scores must be a non-empty list +``` + +--- + +## Key-Value Generic Arrays (`array` & `T[]`) + +TypePHP enforces specific key and value types on associative or indexed arrays: + +### Generic Key-Value Arrays (`array`) + +```php +/** + * @param array $userScores + */ +function recordScores(array $userScores): void +{ + // ... +} + +// Valid Call +recordScores(['alice' => 100, 'bob' => 95]); + +// Invalid Call (Key '0' is integer instead of string) +recordScores([0 => 100]); +// Throws: TypeError: recordScores(): Argument $userScores key must be of type string + +// Invalid Call (Value -5 violates positive-int) +recordScores(['alice' => -5]); +// Throws: TypeError: recordScores(): Argument $userScores['alice'] must be of type positive-int +``` + +### Typed Scalar, Refinement, & Callable Arrays (`positive-int[]`, `non-empty-string[]`, `callable[]`) + +In addition to typed class arrays (`User[]`), TypePHP validates arrays of primitives, scalar refinements, callables, or shapes using `T[]` syntax: + +```php +/** + * @param positive-int[] $ids + * @param non-empty-string[] $tags + * @param callable[] $callbacks + * @param array{id: positive-int}[] $userShapes + */ +function processTypedArrays(array $ids, array $tags, array $callbacks, array $userShapes): void +{ + // ... +} + +// Valid Call +processTypedArrays( + ids: [10, 20, 30], + tags: ['php', 'pest'], + callbacks: [fn () => null, 'strlen'], + userShapes: [['id' => 1], ['id' => 2]] +); + +// Invalid Call (-50 violates positive-int[]) +processTypedArrays( + ids: [10, -50, 30], + tags: ['php', 'pest'], + callbacks: [fn () => null], + userShapes: [['id' => 1]] +); +// Throws: TypeError: processTypedArrays(): Argument $ids[1] must be of type positive-int, negative int (-50) given +``` + +> **Performance Optimization:** When validating arrays of objects (such as `User[]`), TypePHP memoizes previously checked object instances in `\WeakMap`. If the same object instance appears multiple times in a collection, its type is checked once and retrieved in O(1) time on subsequent accesses. + +--- + +## Deeply Nested Arrays & Lists (`array>`) + +TypePHP recursively validates deeply nested array structures down to any depth: + +```php +/** + * @param array> $matrix + */ +function processMatrix(array $matrix): void +{ + // ... +} + +// Valid Call +processMatrix([ + 'math' => [100, 95], + 'science' => [88, 92], +]); + +// Invalid Call (Nested list item -50 violates positive-int) +processMatrix([ + 'math' => [100, -50], +]); +// Throws: TypeError: processMatrix(): Argument $matrix['math'][1] must be of type positive-int +``` + +--- + +## Generics inside Typed Arrays & Shapes (`list>`) + +TypePHP validates generic container objects nested inside arrays or array shapes: + +```php +use App\Generics\Producer; +use App\Models\Dog; +use App\Models\Car; + +/** + * @param list> $producers + * @param array{items: list>, count: positive-int} $payload + */ +function processGenericList(array $producers, array $payload): void +{ + // ... +} + +// Valid Call +processGenericList( + [new Producer(new Dog()), new Producer(new Dog())], + ['items' => [new Producer(new Dog())], 'count' => 1] +); + +// Invalid Call (Producer holds Car instead of Dog) +processGenericList( + [new Producer(new Dog()), new Producer(new Car())], + ['items' => [new Producer(new Dog())], 'count' => 1] +); +// Throws: TypeError: processGenericList(): Argument $producers[1] must be an instance of Producer +``` + +--- + +## Array Shapes (`array{key: type}`) + +Array shapes define exact key-value contracts for associative arrays. + +### Required vs. Optional Keys + +Mark optional keys with a question mark (`key?: type`): + +```php +/** + * @param array{id: positive-int, username: non-empty-string, role?: 'admin'|'user'} $payload + */ +function saveUserPayload(array $payload): void +{ + // ... +} + +// Valid Call (Optional 'role' key omitted) +saveUserPayload(['id' => 10, 'username' => 'Alice']); + +// Valid Call (Optional 'role' key provided) +saveUserPayload(['id' => 10, 'username' => 'Alice', 'role' => 'admin']); + +// Invalid Call (Missing required 'username' key) +saveUserPayload(['id' => 10]); +// Throws: TypeError: saveUserPayload(): Argument $payload is missing required key 'username' +``` + +### Sealed vs. Unsealed Shapes + +By default, array shapes are **sealed**. Any unexpected extra keys in the array will trigger a `TypeError`. + +To allow additional dynamic keys, define an **unsealed shape** using `...` syntax: + +```php +/** + * Unsealed Shape: Requires 'id', but permits additional string-string pairs + * + * @param array{id: positive-int, ...} $options + */ +function processUnsealedOptions(array $options): void +{ + // ... +} + +// Valid Call (Includes extra string key 'category') +processUnsealedOptions(['id' => 10, 'category' => 'admin']); + +// Invalid Call (Extra key 'code' has integer value 999 instead of string) +processUnsealedOptions(['id' => 10, 'code' => 999]); +// Throws: TypeError: processUnsealedOptions(): Argument $options['code'] must be of type string +``` + +--- + +## Positional Tuple Shapes (`array{0: T1, 1: T2}`) + +Define fixed-length, positional array tuples: + +```php +/** + * @param array{0: positive-int, 1: non-empty-string} $tuple + */ +function processTuple(array $tuple): void +{ + // ... +} + +// Valid Call +processTuple([100, 'success']); + +// Invalid Call (Index 0 is negative integer) +processTuple([-5, 'success']); +// Throws: TypeError: processTuple(): Argument $tuple['0'] must be of type positive-int +``` + +--- + +## Object Shapes (`object{prop: type}` & `stdClass{prop: type}`) + +Define property shape contracts for generic objects or strictly for `stdClass` instances: + +### Generic Object Shapes (`object{prop: type}`) + +Accepts any object or `stdClass` matching the property shape: + +```php +/** + * @param object{id: positive-int, name: non-empty-string} $user + */ +function processObjectShape(object $user): void +{ + // ... +} + +$std = new stdClass(); +$std->id = 42; +$std->name = 'Alice'; + +processObjectShape($std); // Valid +``` + +### Strict `stdClass` Shapes (`stdClass{prop: type}`) + +Strictly requires a `stdClass` instance, rejecting custom class instances: + +```php +/** + * @param stdClass{id: positive-int, name: non-empty-string} $payload + */ +function processStrictStdClass(object $payload): void +{ + // ... +} + +// Rejects custom class instances even if they possess 'id' and 'name' properties! +class CustomUser { public int $id = 42; public string $name = 'Alice'; } + +processStrictStdClass(new CustomUser()); +// Throws: TypeError: processStrictStdClass(): Argument $payload must be an instance of stdClass +``` diff --git a/docs/supported-types/callables-and-closures.md b/docs/supported-types/callables-and-closures.md new file mode 100644 index 0000000..52d5ded --- /dev/null +++ b/docs/supported-types/callables-and-closures.md @@ -0,0 +1,247 @@ +# Callables & Closures + +TypePHP provides lazy runtime interception for callbacks, Closures, array callables, first-class callables, and PHPStan static-closure specifications. + +--- + +## How Callback Interception Works (`CallableWrapper`) + +When a callable parameter or local variable is annotated with a callable contract (such as `callable(positive-int): non-empty-string`), TypePHP wraps the callable in a lazy interceptor proxy: + +1. **Lazy Execution:** TypePHP does not execute the callback immediately when passed as an argument. +2. **Input Validation:** When the wrapped callback is invoked, TypePHP validates the arguments passed into the callback. +3. **Output Validation:** When the callback returns, TypePHP validates the returned value against the callback's declared return contract. + +--- + +## Basic Callable Contracts (`callable(T1, T2): R`) + +Declare argument and return types for callbacks using `callable(Type1, Type2): ReturnType` syntax: + +```php + 0 && strlen($name) > 0; +}); + +// Invalid Callback (Returns integer 123 instead of bool) +processUserCallback(function (int $id, string $name): int { + return 123; +}); +// Throws: TypeError: Callback $callback return value must be of type bool, int (123) given +``` + +--- + +## Complex Parameter & Return Contracts in Callables + +Because `CallableWrapper` delegates callback argument and return validation directly to TypePHP's central validator engine, **all complex types (generics, array shapes, lists, unions, intersections) are fully enforced inside callback signatures**: + +```php +use App\Generics\Producer; +use App\Models\Dog; +use App\Models\Car; + +/** + * Callback accepting a generic Producer and list, returning an array shape + * + * @param callable(Producer, list): array{status: 'success'|'error', count: positive-int} $processor + */ +function executeComplexCallback(callable $processor): void +{ + $processor(new Producer(new Dog()), [10, 20]); +} + +// Valid Call +executeComplexCallback(function (Producer $producer, array $ids): array { + return ['status' => 'success', 'count' => 2]; +}); + +// Invalid Execution (Callback returns negative count -5 violating positive-int in array shape) +executeComplexCallback(function (Producer $producer, array $ids): array { + return ['status' => 'success', 'count' => -5]; +}); +// Throws: TypeError: Callback $processor return value['count'] must be of type positive-int, negative int (-5) given +``` + +--- + +## Strict Closure Instance Contracts (`Closure(T): R`) + +When you specify `Closure(T): R` instead of `callable(T): R`, TypePHP strictly requires a native `Closure` instance, rejecting string function names or array callables: + +```php +/** + * Strictly requires a native Closure instance + * + * @param Closure(positive-int): non-empty-string $closure + */ +function executeClosureOnly(Closure $closure): string +{ + return $closure(42); +} + +// Valid Call +executeClosureOnly(fn (int $id) => "user_{$id}"); + +// Invalid Call (Passing string function name 'strlen' where Closure was required) +executeClosureOnly('strlen'); +// Throws: TypeError: Argument $closure must be of type Closure, string 'strlen' given +``` + +--- + +## Array & First-Class Callables (PHP 8.1+) + +TypePHP seamlessly intercepts array callables and PHP 8.1+ First-Class Callable syntax (`$obj->method(...)`): + +```php +class UserService +{ + public function formatUser(int $id): string + { + return "user_{$id}"; + } + + public static function staticFormat(int $id): string + { + return "static_user_{$id}"; + } +} + +/** + * @param callable(positive-int): non-empty-string $formatter + */ +function executeFormatter(callable $formatter): string +{ + return $formatter(100); +} + +$service = new UserService(); + +// 1. Instance Method Array Callable +executeFormatter([$service, 'formatUser']); // Valid + +// 2. Static Method Array Callable +executeFormatter([UserService::class, 'staticFormat']); // Valid + +// 3. PHP 8.1+ First-Class Callable Syntax +executeFormatter($service->formatUser(...)); // Valid +``` + +--- + +## Advanced PHPStan Callable Specifications + +TypePHP supports advanced callback specifications including variadic parameters, optional parameters, and static closures: + +### Variadic Callback Parameters (`callable(T ...$items): R`) + +```php +/** + * @param callable(positive-int ...$ids): void $callback + */ +function processVariadicCallback(callable $callback): void +{ + $callback(10, 20, 30); +} + +processVariadicCallback(function (int ...$ids) { + // Valid: Every variadic argument is validated against positive-int +}); +``` + +### Optional Callback Parameters (`callable(T1, T2=): R`) + +Append an equals sign (`T=`) to denote optional callback parameters: + +```php +/** + * Second callback parameter $name is optional + * + * @param callable(positive-int, non-empty-string=): bool $callback + */ +function processOptionalCallback(callable $callback): bool +{ + return $callback(10); // 2nd argument omitted +} +``` + +### Static Closures (`static-closure`) + +Enforce that a closure must be declared as `static` (not bound to `$this`): + +```php +/** + * @param static-closure(int): string $closure + */ +function processStaticClosure(Closure $closure): string +{ + return $closure(100); +} + +// Valid (Static closure) +processStaticClosure(static fn (int $id) => "static_{$id}"); + +// Invalid (Non-static closure bound to $this) +processStaticClosure(fn (int $id) => "bound_{$id}"); +// Throws: TypeError: Argument $closure must be a static Closure (not bound to $this) +``` + +--- + +## Inline `@var` Callable Contracts + +Enforce argument and return contracts on local variables assigned with inline `@var` callable docblocks: + +```php +/** @var callable(positive-int, non-empty-string): bool $formatter */ +$formatter = fn (int $id, string $name) => strlen($name) > 0; + +$formatter(10, 'Alice'); // Valid + +$formatter(-5, 'Alice'); +// Throws: TypeError: Variable $formatter: Callback argument #1 must be of type positive-int, negative int (-5) given +``` + +### Higher-Order Callables + +TypePHP supports higher-order callables returning other callables, validating both outer factory arguments and inner callback returns: + +```php +/** @var callable(positive-int): (callable(non-empty-string): non-empty-string) $factory */ +$factory = function (int $multiplier): callable { + return function (string $prefix) use ($multiplier): string { + if ($prefix === 'invalid') { + return ''; // Violates return non-empty-string! + } + + return str_repeat($prefix, $multiplier); + }; +}; + +// Valid Execution +$repeat3 = $factory(3); +$result = $repeat3('abc'); // Returns 'abcabcabc' + +// Invalid Factory Argument ($multiplier = -5 violates positive-int) +$factory(-5); +// Throws: TypeError: Variable $factory: Callback argument #1 must be of type positive-int, negative int (-5) given + +// Invalid Inner Callback Return Value ('invalid' returns '' violating non-empty-string) +$repeat3 = $factory(3); +$repeat3('invalid'); +// Throws: TypeError: Variable $factory: Callback return value must be of type non-empty-string, empty string ('') given +``` \ No newline at end of file diff --git a/docs/supported-types/primitives-and-scalars.md b/docs/supported-types/primitives-and-scalars.md new file mode 100644 index 0000000..7576370 --- /dev/null +++ b/docs/supported-types/primitives-and-scalars.md @@ -0,0 +1,280 @@ +# Primitives & Scalars + +TypePHP provides runtime enforcement for all native PHP primitives, extended integer ranges, string refinements, class-string subtypes, float constraints, resources, and special return control types. + +--- + +## Native PHP Primitives + +TypePHP validates standard PHP primitive types in function parameters, return values, properties, and `@var` local assignments: + +| Primitive Keyword | Validated PHP Types | Example Values | +| :--- | :--- | :--- | +| **`int`**, **`integer`** | Native integers | `10`, `-5`, `0` | +| **`string`** | Native string scalars | `'hello'`, `''`, `'123'` | +| **`float`**, **`double`** | Floating-point numbers & integers | `12.34`, `0.0`, `-5.5`, `10` | +| **`bool`**, **`boolean`** | Native booleans | `true`, `false` | +| **`null`** | Null value | `null` | +| **`mixed`** | Any value | Always valid (Zero overhead) | +| **`scalar`** | Any PHP scalar (`int`, `string`, `float`, `bool`) | `10`, `'hello'`, `true` | + +```php +/** + * @param int $id + * @param string $name + * @param bool $active + */ +function processPrimitive(int $id, string $name, bool $active): void +{ + // ... +} +``` + +--- + +## Integer Refinements and Ranges + +TypePHP enforces exact value constraints and bounds on integer parameters: + +| Refinement Keyword | Constraint Rule | Valid Examples | Invalid Examples | +| :--- | :--- | :--- | :--- | +| **`positive-int`** | Integer $> 0$ | `1`, `42`, `100` | `0`, `-5` | +| **`negative-int`** | Integer $< 0$ | `-1`, `-42` | `0`, `5` | +| **`non-positive-int`** | Integer $\le 0$ | `0`, `-1`, `-10` | `1`, `5` | +| **`non-negative-int`** | Integer $\ge 0$ | `0`, `1`, `100` | `-1`, `-5` | +| **`non-zero-int`** | Integer $\ne 0$ | `1`, `-1`, `100` | `0` | +| **`unsigned-int`** | Integer $\ge 0$ | `0`, `10`, `50` | `-10` | + +### Integer Bounds (`int`) + +Define explicit minimum and maximum bounds for integers using range syntax: + +```php +/** + * @param int<1, 100> $percentage // Range: 1 to 100 inclusive + * @param int<0, max> $offset // Range: 0 to infinity (non-negative) + * @param int $maxLimit // Range: negative infinity to 10 + */ +function setRange(int $percentage, int $offset, int $maxLimit): void +{ + // ... +} + +// Valid Call +setRange(50, 0, 5); + +// Invalid Call ($percentage = 150 exceeds max bound 100) +setRange(150, 0, 5); +// Throws: TypeError: setRange(): Argument $percentage must be <= 100, 150 given +``` + +--- + +## String Refinements + +Validate string lengths, formatting, character casing, and truthiness at runtime: + +| Refinement Keyword | Constraint Rule | Valid Examples | Invalid Examples | +| :--- | :--- | :--- | :--- | +| **`non-empty-string`** | String length $> 0$ | `'hello'`, `'1'` | `''` (empty string) | +| **`numeric-string`** | `is_numeric($val) === true` | `'123'`, `'45.67'`, `'-10'` | `'abc'`, `''` | +| **`lowercase-string`** | `strtolower($val) === $val` | `'hello'`, `'user_100'` | `'Hello'`, `'ADMIN'` | +| **`non-empty-lowercase-string`** | Non-empty & lowercase | `'hello'`, `'abc'` | `''`, `'Hello'` | +| **`literal-string`** | String scalar | `'active'`, `'user'` | Non-strings | +| **`truthy-string`**, **`non-falsy-string`** | Evaluates to `true` in boolean context | `'hello'`, `'1'` | `''`, `'0'` | + +```php +/** + * @param non-empty-string $title + * @param numeric-string $amount + * @param lowercase-string $slug + */ +function createPost(string $title, string $amount, string $slug): void +{ + // ... +} + +// Valid Call +createPost('Welcome', '99.99', 'welcome-post'); + +// Invalid Call ($title is empty string) +createPost('', '99.99', 'welcome-post'); +// Throws: TypeError: createPost(): Argument $title must be of type non-empty-string +``` + +--- + +## Class-String Subtypes and Generics (`class-string`) + +Enforce that string parameters contain valid class, interface, trait, or enum references, with optional generic bounds (`class-string`): + +| Keyword | Validation Rule | Valid Examples | Invalid Examples | +| :--- | :--- | :--- | :--- | +| **`class-string`** | Valid class, interface, trait, or enum | `User::class`, `stdClass::class` | `'NonExistentClass'` | +| **`class-string`** | Valid class-string that extends or matches `T` | `Dog::class` for `class-string` | `Car::class` for `class-string` | +| **`interface-string`** | `interface_exists($val) === true` | `DateTimeInterface::class` | `stdClass::class` | +| **`trait-string`** | `trait_exists($val) === true` | `LoggerTrait::class` | `stdClass::class` | +| **`enum-string`** | `enum_exists($val) === true` | `StatusEnum::class` | `stdClass::class` | + +### Generic `class-string` Factories + +When combined with `@template T`, `class-string` binds the template parameter from the class name string and enforces a matching return type: + +```php +abstract class Animal {} +class Dog extends Animal {} +class Car {} // Not an Animal! + +/** + * Generic Factory Function + * + * @template T of Animal + * + * @param class-string $class + * @return T + */ +function makeAnimal(string $class): Animal +{ + return new $class(); +} + +// Valid Call (Dog extends Animal, binds T = Dog) +$dog = makeAnimal(Dog::class); + +// Invalid Call (Car does not extend Animal) +makeAnimal(Car::class); +// Throws: TypeError: makeAnimal(): Argument $class (class-string) must be a class-string of Animal, 'Car' given +``` + +--- + +## Float Refinements + +TypePHP enforces signs and bounds on floating-point parameters: + +| Refinement Keyword | Constraint Rule | Valid Examples | Invalid Examples | +| :--- | :--- | :--- | :--- | +| **`positive-float`** | Float $> 0.0$ | `12.34`, `0.01` | `0.0`, `-5.5` | +| **`negative-float`** | Float $< 0.0$ | `-12.34`, `-0.01` | `0.0`, `5.5` | +| **`non-positive-float`** | Float $\le 0.0$ | `0.0`, `-1.5` | `1.5` | +| **`non-negative-float`** | Float $\ge 0.0$ | `0.0`, `1.5` | `-1.5` | +| **`non-zero-float`** | Float $\ne 0.0$ | `1.5`, `-1.5` | `0.0` | + +```php +/** + * @param positive-float $rate + * @param non-zero-float $delta + */ +function applyRate(float $rate, float $delta): void +{ + // ... +} + +// Valid Call +applyRate(0.05, -1.2); + +// Invalid Call ($rate = 0.0 is not positive-float) +applyRate(0.0, -1.2); +// Throws: TypeError: applyRate(): Argument $rate must be of type positive-float +``` + +--- + +## Truthiness and Numeric Types + +Validate boolean truthiness or generic numeric inputs: + +| Keyword | Validation Rule | Valid Examples | Invalid Examples | +| :--- | :--- | :--- | :--- | +| **`truthy`** | Evaluates to `true` in boolean context | `'hello'`, `1`, `[1]`, `true` | `0`, `''`, `null`, `false` | +| **`falsy`**, **`falsey`** | Evaluates to `false` in boolean context | `0`, `''`, `null`, `false`, `[]` | `'hello'`, `1`, `true` | +| **`numeric`**, **`number`** | Integer, Float, or Numeric String | `10`, `12.34`, `'99.9'` | `'abc'`, `null` | + +```php +/** + * @param truthy $flag + * @param numeric $amount + */ +function processFlag(mixed $flag, mixed $amount): void +{ + // ... +} + +// Valid Call +processFlag('valid', '99.9'); + +// Invalid Call ($flag = 0 is falsy) +processFlag(0, '99.9'); +// Throws: TypeError: processFlag(): Argument $flag must be of type truthy +``` + +--- + +## Resources + +Validate active open or closed PHP resource handles: + +| Keyword | Validation Rule | Valid Examples | Invalid Examples | +| :--- | :--- | :--- | :--- | +| **`resource`**, **`open-resource`** | `is_resource($val) === true` | `fopen('file.txt', 'r')` | Closed stream, String | +| **`closed-resource`** | Stream handle that was closed | Closed stream handle (`fclose($fp)`) | Open stream, String | + +```php +/** + * @param open-resource $stream + */ +function processStream($stream): void +{ + // ... +} + +$fp = fopen('php://memory', 'r'); +processStream($fp); // Valid + +fclose($fp); +processStream($fp); // Invalid: Stream was closed +// Throws: TypeError: processStream(): Argument $stream must be of type open-resource +``` + +--- + +## Special Control Return Types (`void` & `never`) + +Enforce function exit behaviors: + +### `void` +Verifies that a function returns `null` or omits a return expression: + +```php +/** + * @return void + */ +function processVoid(): void +{ + // Valid void function +} +``` + +### `never` (`no-return`, `never-return`, `never-returns`) +Verifies that a function **never returns normally** (it must either throw an exception or call `exit()`): + +```php +/** + * @return never + */ +function haltExecution(): void +{ + throw new RuntimeException('Execution stopped'); // Valid: Exits via exception +} + +/** + * @return never + */ +function badHaltExecution(): string +{ + return 'unexpected_return'; // Invalid: Function returned a value! +} + +badHaltExecution(); +// Throws: TypeError: badHaltExecution(): Return value must be of type never +``` diff --git a/src/Resolver/TemplateManager.php b/src/Resolver/TemplateManager.php index 896772e..a8fbde3 100644 --- a/src/Resolver/TemplateManager.php +++ b/src/Resolver/TemplateManager.php @@ -142,6 +142,49 @@ public static function getBoundTemplatesForInstance(object $instance): array return []; } + /** + * Retrieves all declared template variances ('covariant', 'contravariant', 'invariant') for an object instance. + * + * @return array + */ + public static function getTemplateVariances(object $instance): array + { + $className = get_class($instance); + + try { + $ref = new \ReflectionClass($className); + $classDoc = $ref->getDocComment(); + + if ($classDoc !== false) { + [$phpDocParser, $lexer] = self::getPhpDocParserComponents(); + + $classTokens = new TokenIterator($lexer->tokenize($classDoc)); + $classPhpDocNode = $phpDocParser->parse($classTokens); + + $variances = []; + foreach ($classPhpDocNode->getTags() as $tagNode) { + if ($tagNode->value instanceof TemplateTagValueNode) { + $tagName = strtolower($tagNode->name); + + if (str_contains($tagName, 'covariant')) { + $variances[$tagNode->value->name] = 'covariant'; + } elseif (str_contains($tagName, 'contravariant')) { + $variances[$tagNode->value->name] = 'contravariant'; + } else { + $variances[$tagNode->value->name] = 'invariant'; + } + } + } + + return $variances; + } + } catch (\Throwable $e) { + // Silently ignore reflection errors + } + + return []; + } + /** * Checks if a template name is bound in the current instance or call stack frame. */ @@ -664,4 +707,4 @@ private static function getTypeParserComponents(): array return [$typeParser, $lexer]; } -} \ No newline at end of file +} diff --git a/src/TypePHP.php b/src/TypePHP.php index a2072ca..b49fce6 100644 --- a/src/TypePHP.php +++ b/src/TypePHP.php @@ -58,6 +58,37 @@ public static function getGenericTypes(object $instance): array return $types; } + /** + * Returns the declared variance ('covariant', 'contravariant', or 'invariant') for a template parameter on an object instance. + */ + public static function getGenericVariance(object $instance, ?string $templateName = null): string + { + $variances = self::getGenericVariances($instance); + if (count($variances) === 0) { + return 'invariant'; + } + + if ($templateName !== null && isset($variances[$templateName])) { + return $variances[$templateName]; + } + + if (count($variances) === 1) { + return reset($variances); + } + + return $variances['T'] ?? 'invariant'; + } + + /** + * Returns all declared template variances ('covariant', 'contravariant', or 'invariant') for an object instance. + * + * @return array + */ + public static function getGenericVariances(object $instance): array + { + return TemplateManager::getTemplateVariances($instance); + } + /** * Returns the current resolved global configuration settings. * diff --git a/src/Validator/IdentifierValidator.php b/src/Validator/IdentifierValidator.php index 6ffa92d..4c1125d 100644 --- a/src/Validator/IdentifierValidator.php +++ b/src/Validator/IdentifierValidator.php @@ -29,7 +29,7 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali 'array' => \is_array($value), 'list' => \is_array($value) && (\count($value) === 0 || array_is_list($value)), 'object', 'self', 'static', 'parent', '$this' => \is_object($value), - 'callable' => \is_callable($value), + 'callable', 'pure-callable' => \is_callable($value), 'iterable' => is_iterable($value), 'resource' => \is_resource($value), 'null' => $value === null, @@ -62,7 +62,7 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali 'lowercase-string' => \is_string($value) && strtolower($value) === $value, 'non-empty-lowercase-string' => \is_string($value) && $value !== '' && strtolower($value) === $value, 'literal-string' => \is_string($value), - 'truthy-string' => \is_string($value) && (bool) $value === true, + 'truthy-string', 'non-falsy-string' => \is_string($value) && (bool) $value === true, 'non-empty-array' => \is_array($value) && \count($value) > 0, 'non-empty-list' => \is_array($value) && \count($value) > 0 && array_is_list($value), 'number', 'numeric' => \is_int($value) || \is_float($value) || (\is_string($value) && is_numeric($value)), @@ -80,4 +80,4 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali return null; } -} +} \ No newline at end of file diff --git a/tests/Unit/TypePHPTest.php b/tests/Unit/TypePHPTest.php index 67227f3..f884d08 100644 --- a/tests/Unit/TypePHPTest.php +++ b/tests/Unit/TypePHPTest.php @@ -6,7 +6,9 @@ use TypePHP\Tests\Fixtures\Domain\Cat; use TypePHP\Tests\Fixtures\Domain\Dog; +use TypePHP\Tests\Fixtures\Generics\DogRepository; use TypePHP\Tests\Fixtures\Generics\GenericCollection; +use TypePHP\Tests\Fixtures\Generics\Producer; use TypePHP\TypePHP; /** @@ -18,6 +20,16 @@ class CustomTemplateNameBox public mixed $item = null; } +/** + * @template K + * @template V + */ +class MultiTemplateDictionary +{ + /** @var array */ + public array $map = []; +} + describe('TypePHP Public Facade Unit Tests', function () { afterEach(function () { TypePHP::resetConfig(); @@ -28,8 +40,7 @@ class CustomTemplateNameBox expect($config)->toBeArray() ->and($config)->toHaveKey('cache') - ->and($config)->toHaveKey('inline_vars') - ; + ->and($config)->toHaveKey('inline_vars'); }); test('dynamically overrides configuration settings using setConfig', function () { @@ -52,13 +63,57 @@ class CustomTemplateNameBox expect(TypePHP::getConfig()['cache'])->toBeTrue(); }); - test('inspects single template parameter automatically even if custom template name is used', function () { + test('inspects runtime reified generic types across single, custom, multi, and inherited template instances', function () { + // Standard Single Template (GenericCollection vs GenericCollection) + /** @var GenericCollection $dogCollection */ + $dogCollection = new GenericCollection(); + + /** @var GenericCollection $catCollection */ + $catCollection = new GenericCollection(); + + expect(TypePHP::getGenericType($dogCollection))->toBe(Dog::class) + ->and(TypePHP::getGenericType($catCollection))->toBe(Cat::class) + ->and(TypePHP::getGenericTypes($dogCollection))->toBe(['T' => Dog::class]); + + // Custom Named Single Template (@template ItemType) /** @var CustomTemplateNameBox $box */ $box = new CustomTemplateNameBox(); - // Automatically inspects 'ItemType' without needing to guess the template name! expect(TypePHP::getGenericType($box))->toBe(Dog::class) ->and(TypePHP::getGenericType($box, 'ItemType'))->toBe(Dog::class) ->and(TypePHP::getGenericTypes($box))->toBe(['ItemType' => Dog::class]); + + // Multiple Templates (@template K, @template V) + /** @var MultiTemplateDictionary $dict */ + $dict = new MultiTemplateDictionary(); + + expect(TypePHP::getGenericType($dict, 'K'))->toBe('string') + ->and(TypePHP::getGenericType($dict, 'V'))->toBe(Dog::class) + ->and(TypePHP::getGenericTypes($dict))->toBe(['K' => 'string', 'V' => Dog::class]); + + // Inherited Generic Class (@extends Repository) + $dogRepo = new DogRepository(); + + expect(TypePHP::getGenericType($dogRepo))->toBe(Dog::class) + ->and(TypePHP::getGenericTypes($dogRepo))->toBe(['T' => Dog::class]); + + // Unannotated Instance before and after first-use type inference + $mystery = new GenericCollection(); + + expect(TypePHP::getGenericType($mystery))->toBeNull() + ->and(TypePHP::getGenericTypes($mystery))->toBeEmpty(); + + $mystery->add(new Dog()); // First method call infers T = Dog! + + expect(TypePHP::getGenericType($mystery))->toBe(Dog::class) + ->and(TypePHP::getGenericTypes($mystery))->toBe(['T' => Dog::class]); + }); + + test('inspects declared template variances on object instances', function () { + /** @var Producer $producer */ + $producer = new Producer(new Dog()); + + expect(TypePHP::getGenericVariance($producer))->toBe('covariant') + ->and(TypePHP::getGenericVariances($producer))->toBe(['T' => 'covariant']); }); -}); +}); \ No newline at end of file From 63d3997ffcaf7b2148b3ac2bfa9b6bb58e5c0532 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 7 Aug 2026 20:08:31 +0800 Subject: [PATCH 07/15] Add documentation for iterators and generators, including lazy validation and generator contracts --- .../iterators-and-generators.md | 168 ++++++++++++++++++ internals/test-generator-complex.php | 79 ++++++++ 2 files changed, 247 insertions(+) create mode 100644 docs/supported-types/iterators-and-generators.md create mode 100644 internals/test-generator-complex.php diff --git a/docs/supported-types/iterators-and-generators.md b/docs/supported-types/iterators-and-generators.md new file mode 100644 index 0000000..f09bc9c --- /dev/null +++ b/docs/supported-types/iterators-and-generators.md @@ -0,0 +1,168 @@ +# Iterators & Generators + +TypePHP provides lazy runtime validation for `Traversable` objects, `Iterator` instances, and PHP `Generator` functions, validating yielded keys, values, and generator inputs (`$gen->send()`) on-the-fly during iteration. + +--- + +## How Lazy Iteration Works (`IterableWrapper` & `IteratorProxy`) + +When an iterator or generator is passed into a function accepting `Traversable` or returned from a function: +1. **Zero Memory Spikes:** TypePHP does not convert the iterator to an array or load items eagerly into RAM. +2. **On-the-Fly Validation:** Keys (`K`) and values (`V`) are validated lazily during iteration at the exact moment each item is accessed inside `current()` or `yield`. +3. **Rewindability Preserved:** `IteratorProxy` unwraps and preserves iterator rewindability, allowing multiple `foreach` loops over the same wrapped iterator without crashing. +4. **Method & Countable Forwarding:** Forwards `Countable::count()` and custom iterator methods directly to the inner iterator using `__call()`. + +--- + +## Traversable & Iterator Contracts (`Traversable`) + +Validate keys and values on any `Traversable` or `ArrayIterator` instance: + +```php + $items + */ +function processTraversable(Traversable $items): array +{ + $results = []; + foreach ($items as $key => $value) { + $results[$key] = $value; + } + + return $results; +} + +// Valid Call +$iterator = new ArrayIterator(['item1' => 10, 'item2' => 20]); +processTraversable($iterator); + +// Invalid Call (Value -50 violates positive-int) +$badIterator = new ArrayIterator(['item1' => 10, 'item2' => -50]); +processTraversable($badIterator); +// Throws: TypeError: Iterator $items value['item2'] must be of type positive-int, negative int (-50) given +``` + +--- + +## Generator Function Contracts (`Generator`) + +PHP `Generator` functions allow declaring up to 4 generic parameters: +* **`TKey`:** Type of yielded keys (`yield $key => $val`). +* **`TValue`:** Type of yielded values (`yield $val`). +* **`TSend`:** Type of values sent into the generator via `$gen->send($val)`. +* **`TReturn`:** Type of value returned when the generator completes (`return $val`). + +### Yielded Key and Value Validation (`TKey` & `TValue`) + +```php +/** + * @return Generator + */ +function generateScores(): Generator +{ + yield 'alice' => 100; // Valid + yield 'bob' => -50; // Invalid: -50 violates positive-int! +} + +$gen = generateScores(); + +foreach ($gen as $name => $score) { + // Throws lazily on second yield: + // TypeError: Return iterator value must be of type positive-int, negative int (-50) given +} +``` + +--- + +## Generator Input Validation (`$gen->send()` / `TSend`) + +TypePHP validates values sent into a generator via `$gen->send()` against the declared `TSend` template parameter: + +```php +/** + * TKey = int, TValue = string, TSend = positive-int, TReturn = void + * + * @return Generator + */ +function processInteractiveGenerator(): Generator +{ + $receivedInput = yield 1 => 'first_value'; + yield 2 => "processed: {$receivedInput}"; +} + +$gen = processInteractiveGenerator(); +$gen->current(); // Advances to first yield + +// Valid Send (100 satisfies TSend = positive-int) +$gen->send(100); + +// Invalid Send (-500 violates TSend = positive-int) +$gen = processInteractiveGenerator(); +$gen->current(); + +$gen->send(-500); +// Throws: TypeError: processInteractiveGenerator(): Generator sent value (TSend) must be of type positive-int +``` + +--- + +## Delegated Generators (`yield from`) + +TypePHP seamlessly intercepts delegated `yield from` expressions, lazily validating keys and values yielded from nested iterators or arrays: + +```php +/** + * @return Generator + */ +function parentGenerator(): Generator +{ + yield from ['a' => 10, 'b' => 20]; // Valid + yield from ['c' => -99]; // Invalid: -99 violates positive-int +} + +foreach (parentGenerator() as $key => $val) { + // Throws lazily on 'c' => -99: + // TypeError: Return iterator value must be of type positive-int +} +``` + +--- + +## Complex Yield & Send Types (Array Shapes, Generics & Lists) + +Because `GeneratorChecker` delegates key, value, and `TSend` validation directly to TypePHP's central validator engine, **all complex types (array shapes, lists, generic objects, unions) are fully enforced inside generator signatures**: + +```php +use App\Generics\Producer; +use App\Models\Dog; +use App\Models\Car; + +/** + * Generator yielding Array Shapes and accepting Array Shapes in $gen->send() + * + * @return Generator + */ +function processComplexGenerator(): Generator +{ + $input = yield 1 => ['id' => 10, 'username' => 'Alice']; + + // $input is validated against TSend shape array{action: 'approve'|'reject'} when sent! +} + +$gen = processComplexGenerator(); +$firstItem = $gen->current(); // Returns ['id' => 10, 'username' => 'Alice'] + +// Valid Send +$gen->send(['action' => 'approve']); + +// Invalid Send ('action' => 'delete' violates 'approve'|'reject') +$gen = processComplexGenerator(); +$gen->current(); + +$gen->send(['action' => 'delete']); +// Throws: TypeError: processComplexGenerator(): Generator sent value (TSend)['action'] must be of type ('approve' | 'reject') +``` diff --git a/internals/test-generator-complex.php b/internals/test-generator-complex.php new file mode 100644 index 0000000..ded9764 --- /dev/null +++ b/internals/test-generator-complex.php @@ -0,0 +1,79 @@ +send() + * + * @return Generator + */ +function testShapeGenerator(): Generator +{ + $input = yield 1 => ['id' => 10, 'name' => 'Alice']; + yield 2 => ['id' => 20, 'name' => "action_{$input['action']}"]; +} + +/** + * Generator yielding Generic Objects + * + * @return Generator> + */ +function testGenericGenerator(): Generator +{ + yield 1 => new Producer(new Dog()); + yield 2 => new Producer(new Car()); // Invalid: Car is not a Dog! +} + +echo "=== Testing Complex Generator Type Enforcement ===\n\n"; + +// 1. Yielding Array Shapes +echo "1. Testing Generator Yielding Array Shapes:\n"; +$gen1 = testShapeGenerator(); +$firstItem = $gen1->current(); +echo " ✅ Success: Yielded valid shape: " . json_encode($firstItem) . "\n\n"; + +// 2. Sending Valid Shape into Generator (TSend) +echo "2. Testing \$gen->send() with Valid TSend Shape ('action' => 'approve'):\n"; +$secondItem = $gen1->send(['action' => 'approve']); +echo " ✅ Success: Yielded second shape: " . json_encode($secondItem) . "\n\n"; + +// 3. Sending Invalid Shape into Generator (TSend) +echo "3. Testing \$gen->send() with Invalid TSend Shape ('action' => 'delete'):\n"; +$gen2 = testShapeGenerator(); +$gen2->current(); + +try { + $gen2->send(['action' => 'delete']); + echo " ❌ FAIL: Generator accepted invalid TSend action 'delete'!\n"; +} catch (TypeError $e) { + echo " ✅ SUCCESS: Caught expected TypeError on TSend!\n"; + echo " Message: " . $e->getMessage() . "\n\n"; +} + +// 4. Yielding Invalid Generic Object +echo "4. Testing Generator Yielding Invalid Generic Object (Producer):\n"; +$gen3 = testGenericGenerator(); + +try { + foreach ($gen3 as $key => $producer) { + echo " Yielded item #{$key}: " . get_class($producer->item) . "\n"; + } + echo " ❌ FAIL: Generator yielded Producer without throwing TypeError!\n"; +} catch (TypeError $e) { + echo " ✅ SUCCESS: Caught expected TypeError on yield!\n"; + echo " Message: " . $e->getMessage() . "\n"; +} \ No newline at end of file From 49dd16b94e87f1f8865635e57daf1be0e9f8da69 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 8 Aug 2026 16:44:21 +0800 Subject: [PATCH 08/15] set up vitepress for docsite launching --- .github/workflows/deploy-docs.yml | 49 +++ .gitignore | 6 +- docs/{ => .vitepress}/config.mts | 9 +- docs/advanced/exception-handling.md | 114 ++++++ docs/advanced/extensions.md | 88 ++++ docs/advanced/ignore-annotations.md | 158 +++++++ docs/advanced/liskov-and-inheritance.md | 386 ++++++++++++++++++ docs/advanced/vendor-and-path-filtering.md | 106 +++++ docs/core-concepts/function-contracts.md | 31 ++ docs/index.md | 32 +- docs/production/cache-commands.md | 139 +++++++ docs/production/performance-considerations.md | 145 +++++++ docs/production/production-readiness.md | 127 ++++++ package.json | 15 + .../Oop/ChildClassInheritingTraitParent.php | 10 + tests/Fixtures/Oop/DeepTraitWithContracts.php | 17 + tests/Fixtures/Oop/ParentClassWithTrait.php | 10 + tests/SomeTest.php | 11 +- tests/TypeChecking/OopInheritanceTest.php | 30 +- .../PhpAttributesCoexistenceTest.php | 61 +++ 20 files changed, 1517 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/deploy-docs.yml rename docs/{ => .vitepress}/config.mts (89%) create mode 100644 docs/advanced/exception-handling.md create mode 100644 docs/advanced/extensions.md create mode 100644 docs/advanced/ignore-annotations.md create mode 100644 docs/advanced/liskov-and-inheritance.md create mode 100644 docs/advanced/vendor-and-path-filtering.md create mode 100644 docs/production/cache-commands.md create mode 100644 docs/production/performance-considerations.md create mode 100644 docs/production/production-readiness.md create mode 100644 package.json create mode 100644 tests/Fixtures/Oop/ChildClassInheritingTraitParent.php create mode 100644 tests/Fixtures/Oop/DeepTraitWithContracts.php create mode 100644 tests/Fixtures/Oop/ParentClassWithTrait.php create mode 100644 tests/TypeChecking/PhpAttributesCoexistenceTest.php diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 0000000..d94d383 --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,49 @@ +name: Deploy Documentation + +on: + push: + branches: + - main + paths: + - 'docs/**' + - '.github/workflows/deploy-docs.yml' + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: 'pages' + cancel-in-progress: true + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build with VitePress + run: npm run docs:build + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/.vitepress/dist + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 027fb8d..aeaa754 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ composer.lock /vendor /.vscode -/var \ No newline at end of file +/var +/node_modules +package-lock.json +docs/.vitepress/cache +docs/.vitepress/dist \ No newline at end of file diff --git a/docs/config.mts b/docs/.vitepress/config.mts similarity index 89% rename from docs/config.mts rename to docs/.vitepress/config.mts index 46b3a0d..82d2e75 100644 --- a/docs/config.mts +++ b/docs/.vitepress/config.mts @@ -2,14 +2,15 @@ import { defineConfig } from 'vitepress' export default defineConfig({ title: "TypePHP", - description: "Zero-cost, production-ready runtime type enforcer for PHP.", + description: "Runtime Type Enforcement for PHP.", themeConfig: { + siteTitle: "TypePHP", nav: [ { text: 'Home', link: '/' }, { text: 'Documentation', link: '/getting-started/installation' }, { text: 'Architecture', link: '/architecture/how-it-works' }, { text: 'CLI', link: '/production/cache-commands' }, - { text: 'GitHub', link: 'https://github.com/typephp/typephp' } + { text: 'GitHub', link: 'https://github.com/typephp-php/typephp' } ], sidebar: [ { @@ -61,12 +62,12 @@ export default defineConfig({ items: [ { text: 'Production Readiness', link: '/production/production-readiness' }, { text: 'Cache CLI Commands', link: '/production/cache-commands' }, - { text: 'Performance Benchmarks', link: '/production/performance-benchmarks' }, + { text: 'Performance Considerations', link: '/production/performance-considerations' }, ] } ], socialLinks: [ - { icon: 'github', link: 'https://github.com/typephp/typephp' } + { icon: 'github', link: 'https://github.com/typephp-php/typephp' } ], search: { provider: 'local' diff --git a/docs/advanced/exception-handling.md b/docs/advanced/exception-handling.md new file mode 100644 index 0000000..59dccb4 --- /dev/null +++ b/docs/advanced/exception-handling.md @@ -0,0 +1,114 @@ +# Exception Handling + +If you run TypePHP in live production applications—especially for validating external HTTP API payloads, webhooks, or dynamic database records—catching `TypePHP\Exception\TypeError` gives you a clean way to intercept data validation failures gracefully without letting exceptions crash your application. + +TypePHP provides a custom exception class, `TypePHP\Exception\TypeError`, designed for both polymorphic compatibility with PHP's native type system and surgical error handling at application boundaries. + +--- + +## The Exception Class Hierarchy + +All contract violations in TypePHP instantiate `TypePHP\Exception\TypeError`, which extends PHP's native `\TypeError`: + +``` +\Throwable + └── \Error + └── \TypeError + └── TypePHP\Exception\TypeError +``` + +--- + +## Dual Catching Modes + +Because `TypePHP\Exception\TypeError` extends native `\TypeError`, you can choose how broadly or narrowly to catch type failures: + +### 1. Polymorphic Catching (`catch (\TypeError $e)`) + +Catches both native PHP engine type errors (such as passing a string into a native `int` type hint) and TypePHP contract failures: + +```php +try { + processUser(-50); +} catch (\TypeError $e) { + // Catches both native PHP TypeErrors and TypePHP contract failures! +} +``` + +### 2. Specific Contract Catching (`catch (\TypePHP\Exception\TypeError $e)`) + +Specifically catches TypePHP contract violations while allowing native PHP engine errors to bubble up separately: + +```php +use TypePHP\Exception\TypeError as TypePHPTypeError; + +try { + processUser(-50); +} catch (TypePHPTypeError $e) { + // Catches ONLY TypePHP contract violations! +} catch (\TypeError $e) { + // Catches native PHP engine type errors! +} +``` + +--- + +## Call-Site Trace Attribution + +When a function parameter or callback argument fails type validation, TypePHP automatically rewrites the exception's file and line attributes. + +Instead of blaming internal library files, `ErrorFactory` filters out internal frames and attributes `$e->file` and `$e->line` directly to **the exact line of code in the caller file where the invalid argument was passed**, matching native PHP engine behavior. + +--- + +## HTTP API Payload Validation (Framework-Agnostic 422 Responses) + +`TypePHP\Exception\TypeError` is especially useful when validating external HTTP request payloads at application boundaries. + +When dynamic request data fails contract validation, you can catch `TypePHPTypeError` and return a clean HTTP `422 Unprocessable Entity` response: + +```php +namespace App\Http; + +use App\Services\UserService; +use TypePHP\Exception\TypeError as TypePHPTypeError; + +class UserApiController +{ + public function __construct(private UserService $userService) {} + + public function handleRequest(array $requestData): array + { + try { + // UserService enforces @param array{id: positive-int, email: non-empty-string} + $this->userService->registerUser($requestData); + + return [ + 'status' => 200, + 'body' => ['message' => 'User registered successfully'], + ]; + } catch (TypePHPTypeError $e) { + // Convert TypePHP contract failure into an HTTP 422 response + return [ + 'status' => 422, + 'body' => [ + 'error' => 'Unprocessable Entity', + 'message' => $e->getMessage(), + ], + ]; + } + } +} +``` + +--- + +## Human-Readable Error Formatting + +TypePHP's `TypeFormatter` formats invalid values into descriptive human-readable strings inside exception messages: + +* **Integers:** `negative int (-50)`, `zero int (0)`, `int (42)` +* **Strings:** `empty string ('')`, `string 'this_is_a_very_lo...'` +* **Booleans:** `bool (true)`, `bool (false)` +* **Arrays:** `empty array ([])`, `list (3 items)`, `associative array (key 'id')` +* **Objects:** Class FQCN (e.g., `App\Models\Product`) diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md new file mode 100644 index 0000000..d397d03 --- /dev/null +++ b/docs/advanced/extensions.md @@ -0,0 +1,88 @@ +# Building Extensions + +TypePHP provides an Extension System that allows third-party package authors and framework integrations to automatically register path whitelists so end-users can benefit from type checking without having to manually write many include file paths in `typephp.php`, which can be error-prone and hard to manage. + +--- + +## Implementing `ExtensionInterface` + +To create an extension, implement `TypePHP\Extension\ExtensionInterface` and return your package's whitelist include paths: + +```php + + */ + public function getConfig(): array + { + return [ + 'include' => [ + 'vendor/acme/sample-package/**', // Whitelist package files automatically + ], + ]; + } +} +``` + +--- + +## Structural Safety ("Include-Only" Safeguard) + +To protect end-user application stability and prevent third-party package conflicts, TypePHP enforces **Include-Only Authority** on extension configurations: + +1. **Path Whitelisting:** Extensions can ONLY append paths to the global `include` array. +2. **Exclusion Protection:** Extensions are structurally forbidden from adding `exclude` rules. A third-party extension can never blacklist your application's `src/` directory or another package. +3. **Feature Flag Immunity:** Extensions cannot modify global feature flags (`enabled`, `cache`, `inline_vars`). The end-user's `typephp.php` holds ultimate authority. + +--- + +## Registering Extensions in `typephp.php` + +End-users explicitly register extension classes in their `typephp.php` configuration file: + +```php +// typephp.php +return [ + /* + |-------------------------------------------------------------------------- + | Registered Extensions + |-------------------------------------------------------------------------- + | Explicitly list third-party extension classes. + */ + 'extensions' => [ + \Acme\SamplePackage\TypePHPExtension::class, + ], + + 'include' => [ + 'src/**', + 'app/**', + 'tests/**', + ], + + 'exclude' => [ + 'vendor/**', + 'storage/**', + ], +]; +``` + +--- + +## Future Expansion Roadmap + +In current releases, the Extension System focuses strictly on path whitelisting. In future releases, the Extension System will be expanded to support: + +* **Custom Type Validators:** Registering custom validator strategies for third-party classes or custom PHPDoc tags. +* **Custom Type Resolvers:** Resolving specialized domain-specific type identifiers at runtime. +* **Automated Installer Plugins:** Optional Composer plugins for automatic extension discovery. diff --git a/docs/advanced/ignore-annotations.md b/docs/advanced/ignore-annotations.md new file mode 100644 index 0000000..d5ce305 --- /dev/null +++ b/docs/advanced/ignore-annotations.md @@ -0,0 +1,158 @@ +# Ignore Annotations (`@typephp-ignore`) + +TypePHP provides docblock suppression tags to skip type enforcement on legacy code, un-refactored methods, or performance-critical loops without requiring you to remove PHPDoc annotations. + +--- + +## File-Level Suppression (`@typephp-ignore-file`) + +Place `@typephp-ignore-file` (or `@typephp-disable-file`) in a docblock at the top of a PHP file to skip AST transformation and type-checking for the entire file: + +```php + **Technical Note & Coding Convention:** Under the hood, TypePHP scans the raw file contents for `@typephp-ignore-file` before performing AST transformations, meaning the tag will function regardless of its position in the file. However, you should always place `@typephp-ignore-file` at the very top of the file (right after ` $id]; + } + + /** + * Ignored Method - Type-checking skipped for this method only! + * + * @typephp-ignore + * @param positive-int $id + */ + public function legacyImport(int $id): void + { + // ... + } +} + +$service = new UserService(); + +// Normal method still enforces positive-int +$service->findUser(-5); +// Throws: TypeError: UserService::findUser(): Argument $id must be of type positive-int + +// Ignored method skips type-checking! +$service->legacyImport(-500); // Passes without error +``` + +--- + +## Property & Property Hook Suppression (`@typephp-ignore`) + +Add `@typephp-ignore` to a class property or PHP 8.4 property hook docblock to skip property assignment and hook validation: + +```php +class UserProfile +{ + /** + * Normal Property - Validated on assignment + * + * @var positive-int + */ + public int $id = 10; + + /** + * Ignored Property - Type-checking skipped + * + * @typephp-ignore + * @var positive-int + */ + public int $unvalidatedId = 10; + + /** + * Ignored Property Hook - Hook validation skipped + * + * @typephp-ignore + * @var positive-int + */ + public int $unvalidatedHook { + get => $this->_val; + set => $this->_val = $value; + } + + public int $_val = 10; +} + +$profile = new UserProfile(); + +// Ignored property assignment passes -500 without error! +$profile->unvalidatedId = -500; + +// Ignored property hook assignment passes -500 without error! +$profile->unvalidatedHook = -500; +``` + +--- + + +### Updated Section for `docs/advanced/ignore-annotations.md` + +## Forcing Audits in CI/CD (`respect_ignore_tags => false`) + +During automated CI/CD builds or security audits, you can force TypePHP to **bypass all ignore tags** and type-check every method and file containing `@typephp-ignore` without modifying source code. + +Set `'respect_ignore_tags' => false` in your `typephp.php` configuration or dynamically in test setup: + +```php +// In a CI/CD test setup or script: +TypePHP::setConfig(['respect_ignore_tags' => false]); + +// All @typephp-ignore methods and files will now be strictly type-checked! +``` + +> **Path Exclusions vs. DocBlock Suppression:** +> Setting `'respect_ignore_tags' => false` ONLY overrides DocBlock annotations (`@typephp-ignore` and `@typephp-ignore-file`). +> +> Files or directories blacklisted in your `typephp.php` configuration (`'exclude' => ['vendor/**', 'src/Legacy/**']`) remain **unconditionally excluded**. Path-level exclusions in configuration are never overridden by `respect_ignore_tags`. + +--- + +## Summary of Ignore Annotations + +| Annotation Tag | Target Scope | Behavior | +| :--- | :--- | :--- | +| **`@typephp-ignore-file`**, **`@typephp-disable-file`** | File Header | Skips AST transformation for the entire file. | +| **`@typephp-ignore`**, **`@typephp-disable`** | Function / Method | Skips parameter and return contract injection for the target method. | +| **`@typephp-ignore`**, **`@typephp-disable`** | Property / Hook | Skips property assignment and hook validation. | + diff --git a/docs/advanced/liskov-and-inheritance.md b/docs/advanced/liskov-and-inheritance.md new file mode 100644 index 0000000..3c110fc --- /dev/null +++ b/docs/advanced/liskov-and-inheritance.md @@ -0,0 +1,386 @@ +# Liskov Substitution & DocBlock Inheritance + +TypePHP respects the Liskov Substitution Principle (LSP). Child classes, implemented interfaces, traits, and abstract methods automatically inherit PHPDoc type contracts without requiring you to duplicate docblock annotations. + +--- + +## Abstract Method & Interface Contract Inheritance + +When a child class implements an interface or extends an abstract parent class, it inherits all `@param` and `@return` contracts declared on the parent methods: + +```php + $id, 'username' => 'Alice']; + } +} + +$repo = new UserRepository(); + +// Valid Call +$repo->findUser(42); + +// Invalid Call ($id is negative) +$repo->findUser(-5); +// Throws: TypeError: UserRepository::findUser(): Argument $id must be of type positive-int +``` + +--- + +## Deep Multi-Level Inheritance Chains + +TypePHP recursively traverses inheritance trees down to any depth across classes, interfaces, and traits: + +### 4-Level Class Inheritance (`Level 1` $\rightarrow$ `Level 2` $\rightarrow$ `Level 3` $\rightarrow$ `Level 4`) + +```php +abstract class DeepLevel1 +{ + /** + * @param positive-int $id + * @return non-empty-string + */ + abstract public function process(int $id): string; +} + +abstract class DeepLevel2 extends DeepLevel1 {} +abstract class DeepLevel3 extends DeepLevel2 {} + +class DeepLevel4Executor extends DeepLevel3 +{ + // Level 4 concrete class with NO docblock! Inherits from Level 1 root. + public function process(int $id): string + { + return "item_{$id}"; + } +} + +$executor = new DeepLevel4Executor(); + +// $id = -50 violates Level 1 abstract parent's @param positive-int +$executor->process(-50); +// Throws: TypeError: DeepLevel4Executor::process(): Argument $id must be of type positive-int +``` + +### Deep Interface Chains (`RootInterface` $\leftarrow$ `MidInterface` $\leftarrow$ `ChildInterface`) + +```php +interface RootInterface +{ + /** + * @param positive-int $code + */ + public function execute(int $code): bool; +} + +interface MidInterface extends RootInterface {} +interface ChildInterface extends MidInterface {} + +class InterfaceExecutor implements ChildInterface +{ + // Implementation with NO docblock! Inherits from RootInterface. + public function execute(int $code): bool + { + return true; + } +} + +$executor = new InterfaceExecutor(); + +// $code = -10 violates RootInterface's @param positive-int +$executor->execute(-10); +// Throws: TypeError: InterfaceExecutor::execute(): Argument $code must be of type positive-int +``` + +--- + +## PHP 8.4 Interface Property & Hook Inheritance + +In PHP 8.4, interfaces can declare property hooks (`{ get; set; }`). Implementing classes inherit property `@var` contracts directly from the interface: + +```php +interface UserInterface +{ + /** + * @var positive-int + */ + public int $id { get; } + + /** + * @var non-empty-string + */ + public string $username { get; set; } +} + +class User implements UserInterface +{ + // Inherits @var positive-int from UserInterface + public int $id { + get => $this->_id; + } + public int $_id = 10; + + // Inherits @var non-empty-string from UserInterface + public string $username { + get => $this->_username; + set => $this->_username = trim($value); + } + public string $_username = 'Alice'; +} + +$user = new User(); + +// Invalid Read ($user->_id = -5 violates interface's inherited @var positive-int) +$user->_id = -5; +$value = $user->id; +// Throws: TypeError: Property User::$id must be of type positive-int + +// Invalid Write ($username = '' violates interface's inherited @var non-empty-string) +$user->username = ''; +// Throws: TypeError: Property User::$username must be of type non-empty-string +``` + +--- + +## Trait Instance & Static Property Inheritance + +Instance and static properties declared in Traits inherit their `@var` docblock contracts when used by a class: + +```php +trait IdentifiableTrait +{ + /** + * @var positive-int + */ + public int $traitId = 10; + + /** + * @var non-empty-string + */ + public static string $traitVersion = '1.0'; + + public function setTraitId(int $val): void + { + $this->traitId = $val; + } + + public static function setTraitVersion(string $val): void + { + self::$traitVersion = $val; + } +} + +class AppModel +{ + use IdentifiableTrait; +} + +$model = new AppModel(); + +// $val = -50 violates Trait's @var positive-int +$model->setTraitId(-50); +// Throws: TypeError: Property AppModel::$traitId must be of type positive-int + +// $val = '' violates Trait's @var non-empty-string +AppModel::setTraitVersion(''); +// Throws: TypeError: Property AppModel::$traitVersion must be of type non-empty-string +``` +--- +## Trait Inheritance Across Parent-Child Classes + +When a parent class uses a Trait (`ParentClass` uses `LoggerTrait`), any child class extending the parent (`ChildClass extends ParentClass`) automatically inherits all `@param`, `@return`, and `@var` contracts declared on the parent's Trait: + +```php +trait LoggerTrait +{ + /** + * @param positive-int $level + * @return non-empty-string + */ + public function logMessage(int $level, string $msg): string + { + return "log_{$level}_{$msg}"; + } +} + +class ParentService +{ + use LoggerTrait; // Parent class uses trait +} + +class ChildService extends ParentService +{ + // Child class inherits logMessage() without declaring a docblock +} + +$child = new ChildService(); + +// Valid Call +$child->logMessage(10, 'boot'); + +// Invalid Call ($level = -50 violates inherited Trait's @param positive-int) +$child->logMessage(-50, 'boot'); +// Throws: TypeError: ChildService::logMessage(): Argument $level must be of type positive-int +``` +--- + +## Partial Parameter Overriding (Gap-Filling) + +If a child class overrides a method and provides a docblock for **only some** parameters, TypePHP fills in the missing parameter contracts from the parent class or interface: + +```php +class BaseService +{ + /** + * Parent defines contracts for $id and $name + * + * @param positive-int $id + * @param non-empty-string $name + */ + public function update(int $id, string $name): bool + { + return true; + } +} + +class ChildService extends BaseService +{ + /** + * Child overrides ONLY $name to restrict allowed string literals! + * + * @param 'Alice'|'Bob' $name + */ + public function update(int $id, string $name): bool + { + return true; + } +} + +$service = new ChildService(); + +// Valid Call +$service->update(10, 'Alice'); + +// Invalid $id (-5 violates parent's inherited @param positive-int) +$service->update(-5, 'Alice'); +// Throws: TypeError: ChildService::update(): Argument $id must be of type positive-int + +// Invalid $name ('Charlie' violates child's local @param 'Alice'|'Bob') +$service->update(10, 'Charlie'); +// Throws: TypeError: ChildService::update(): Argument $name must be of type ('Alice' | 'Bob') +``` + +--- + +## Parameter Renaming ($id $\rightarrow$ $userId$) + +PHP permits child classes to rename parameters when implementing an interface or extending a class. TypePHP maps inherited parameter contracts by **index position** (0, 1, 2...) rather than parameter name: + +```php +interface UserApiInterface +{ + /** + * Interface uses parameter name $id + * + * @param positive-int $id + */ + public function find(int $id): bool; +} + +class UserApi implements UserApiInterface +{ + // Child renames parameter $id to $userId + public function find(int $userId): bool + { + return true; + } +} + +$api = new UserApi(); + +// $userId = -50 is checked at index 0 against interface's @param positive-int $id! +$api->find(-50); +// Throws: TypeError: UserApi::find(): Argument $userId must be of type positive-int +``` + +--- + +## Trait & Interface Contract Fusion + +When a class implements an Interface and fulfills its methods by using a Trait: + +```php +interface ExecutorInterface +{ + /** + * @param positive-int $code + * @return non-empty-string + */ + public function execute(int $code): string; +} + +trait ExecutorTrait +{ + // Trait method fulfills the Interface with NO docblock + public function execute(int $code): string + { + return "code_{$code}"; + } +} + +class AppExecutor implements ExecutorInterface +{ + use ExecutorTrait; // Trait method fulfills Interface contract +} + +$app = new AppExecutor(); + +// $code = -5 violates ExecutorInterface's @param positive-int +$app->execute(-5); +// Throws: TypeError: AppExecutor::execute(): Argument $code must be of type positive-int +``` + +--- + +## In-Memory Inheritance Caching Performance + +To ensure that resolving complex inheritance chains introduces zero perceptible latency, TypePHP uses a **3-tier in-memory static caching architecture**: + +1. **End-Result Contract Cache (`ContractParser::$cache`):** Caches the fully resolved parameter, return, template, and alias metadata per method string (e.g. `"UserRepository::find"`). +2. **Reflection Hierarchy Cache (`HierarchyResolver`):** Caches the class, parent, interface, and trait Reflection tree (`[Child, Parent, GrandParent, Interface1, Interface2]`). If a class has 20 methods, its inheritance tree is inspected **only once**. +3. **Property Contract Cache (`ContractParser::$propertyCache`):** Caches resolved property `@var` types (`"UserProfile::$id"`). + +### How It Executes at Runtime + +When you call `$userRepo->find(42)` 1,000 times in a loop: +* **Invocation #1:** TypePHP builds the `UserRepository` inheritance tree, parses the docblocks, merges parent gaps, and caches the resolved contract in static RAM. +* **Invocations #2 through #1,000:** TypePHP fetches the pre-resolved contract directly from static RAM in **O(1) constant nanoseconds**—zero Reflection traversal occurs! + +--- + +## Vendor DocBlock Isolation + +TypePHP protects your application from third-party vendor docblock bugs using **Vendor Isolation**: + +* If a parent class or interface is located inside an excluded folder (such as `/vendor/`), TypePHP **ignores its inherited docblocks**. +* This prevents third-party package docblock errors or outdated annotations from causing unexpected `TypeError` exceptions in your application code. diff --git a/docs/advanced/vendor-and-path-filtering.md b/docs/advanced/vendor-and-path-filtering.md new file mode 100644 index 0000000..d8e7c0a --- /dev/null +++ b/docs/advanced/vendor-and-path-filtering.md @@ -0,0 +1,106 @@ +# Vendor & Path Filtering + +TypePHP provides surgical control over which files are intercepted and type-checked. Using pattern specificity, you can whitelist specific vendor packages, blacklist single legacy files, and isolate your application from third-party vendor docblock errors. + +> **Package Authors & Extensions:** Building a third-party package or framework integration and want to automatically whitelist your package directory without requiring users to edit `typephp.php`? See the [TypePHP Extensions](/advanced/extensions) guide. + +--- + +## The Pattern Specificity Algorithm + +When a file path matches both an `include` pattern and an `exclude` pattern, TypePHP determines the outcome by calculating **pattern specificity length** (`strlen($pattern)`): + +$$\text{Winning Pattern} = \max(\text{Specificity Length})$$ + +* **Longer Pattern Wins:** A more specific pattern like `'vendor/acme/package/**'` (length 25) takes precedence over a broader glob like `'vendor/**'` (length 8). +* **Single File Override:** A specific file path like `'src/Legacy/UnsafeFile.php'` (length 25) takes precedence over a directory glob like `'src/**'` (length 6). +* **Tie-Breaker:** If pattern lengths are equal, `exclude` takes precedence to ensure application safety. + +--- + +## Vendor Package Whitelisting + +By default, `vendor/**` is listed in your `exclude` configuration to protect application performance and prevent vendor docblock bleed. + +To type-check a specific third-party vendor package, add its package path to your `include` configuration: + +```php +// typephp.php +return [ + 'include' => [ + 'src/**', + 'app/**', + 'vendor/acme/domain-models/**', // Whitelist specific vendor package! + ], + + 'exclude' => [ + 'vendor/**', // Exclude all other vendor packages + 'storage/**', + ], +]; +``` + +### How TypePHP Evaluates This + +* **`vendor/acme/domain-models/src/User.php`**: + * Matches `include`: `'vendor/acme/domain-models/**'` (Length: 29) + * Matches `exclude`: `'vendor/**'` (Length: 8) + * **Result:** `29 > 8` $\rightarrow$ **Included and Type-Checked!** + +* **`vendor/guzzlehttp/guzzle/src/Client.php`**: + * Matches `include`: None + * Matches `exclude`: `'vendor/**'` (Length: 8) + * **Result:** **Excluded and Ignored!** + +--- + +## Single-File Whitelisting & Blacklisting + +Pattern specificity allows you to target or exclude individual PHP files with single-file precision: + +### Single-File Whitelisting (Inside Excluded Folder) + +Whitelist a single file inside `vendor/` without transforming the rest of the package: + +```php +'include' => [ + 'vendor/monolog/monolog/src/Monolog/Logger.php', // Whitelist single file! +], +'exclude' => [ + 'vendor/**', +], +``` + +### Single-File Blacklisting (Inside Included Folder) + +Blacklist a single legacy file inside an included application directory: + +```php +'include' => [ + 'src/**', // Include all src files by default +], +'exclude' => [ + 'src/Legacy/UnsafeFile.php', // Blacklist this specific legacy file! + 'vendor/**', +], +``` + +> **Granular Code Suppression:** Need to skip type enforcement on a specific method or file without editing path configuration? See the [Ignore Annotations](/advanced/ignore-annotations) guide for `@typephp-ignore` and `@typephp-ignore-file` docblock tags. + +--- + +## Vendor DocBlock Isolation (Preventing DocBlock Bleed) + +Third-party vendor libraries often contain outdated, loose, or buggy PHPDoc annotations. If your application class extends a vendor class, inheriting those vendor docblocks could trigger unexpected `TypeError` exceptions in your code. + +TypePHP prevents this via **Vendor Isolation**: + +* When `ContractParser` resolves method or property inheritance, it checks `FileFilter::isFileExcluded()` on each parent class and interface file. +* If a parent class or interface is located inside an excluded folder (such as `/vendor/`), TypePHP **ignores its inherited docblocks**. +* This keeps your application completely insulated from third-party vendor docblock bugs. + +--- + +## Automatic Non-PHP File Exclusion + +Non-PHP files (`.json`, `.md`, `.css`, `.xml`, `.html`) are automatically rejected by `FileFilter` and `StreamWrapper` before glob evaluation occurs, ensuring zero performance overhead when non-PHP assets are loaded by your application. diff --git a/docs/core-concepts/function-contracts.md b/docs/core-concepts/function-contracts.md index 8f57cd9..ec32f44 100644 --- a/docs/core-concepts/function-contracts.md +++ b/docs/core-concepts/function-contracts.md @@ -243,3 +243,34 @@ formatValue(false, 'hello'); // Evaluates return type as non-empty-string formatValue(true, 'not_an_int'); // Throws: TypeError: formatValue(): Return value must be of type positive-int ``` +--- + +## PHP 8.0+ Attributes Coexistence + +TypePHP seamlessly coexists with native PHP 8.0+ Attributes (`#[Route]`, `#[Inject]`, `#[Validate]`). + +You can place your PHPDoc annotations **either above or below** native PHP attributes on properties, methods/functions. TypePHP's AST engine and PHP's Reflection API process both metadata channels independently without any syntax conflicts: + +```php +// Option A: DocBlock ABOVE Attribute (Supported) +/** + * @param positive-int $id + * @return array{id: positive-int, username: non-empty-string} + */ +#[Route('/user/{id}', method: 'GET')] +public function showUser(int $id): array +{ + return ['id' => $id, 'username' => 'Alice']; +} + +// Option B: DocBlock BELOW Attribute (Supported) +#[Route('/user/{id}', method: 'GET')] +/** + * @param positive-int $id + * @return array{id: positive-int, username: non-empty-string} + */ +public function showUser(int $id): array +{ + return ['id' => $id, 'username' => 'Alice']; +} +``` \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 000f9cc..26c4934 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,7 +11,7 @@ hero: link: /getting-started/installation - theme: alt text: View on GitHub - link: https://github.com/typephp/typephp + link: https://github.com/typephp-php/typephp features: - title: Zero-Cost Performance @@ -24,7 +24,7 @@ features: details: Native support for PHP 8.4 Property Hooks (get/set) and Asymmetric Visibility (public private(set)). --- -## Example Usage +## Real-World Example ```php use App\Models\User; @@ -39,11 +39,33 @@ use TypePHP\Tests\Fixtures\Generics\Collection; */ function processUserBatch(Collection $users, array $options): array { - /** @var positive-int $limit */ - $limit = $options['count']; + /** @var array $typeArray */ + $typeArray = [1, 2, 3, '1']; return [10, 20, 30]; } ``` ---- \ No newline at end of file +--- + +## Precise Stack Trace & Error Reporting + +TypePHP injects single-line guard rails without shifting your source file line numbers. + +When a type contract fails, framework error handlers and test runners (like Pest, PHPUnit, and Whoops) point **directly to the exact line number** where the invalid assignment or argument occurred: + +``` + FAILED Tests\SomeTest > test + + TypeError: Variable $typeArray[3] must be of type int, string '1' given + + at tests/SomeTest.php:7 + 3| declare(strict_types=1); + 4| + 5| test('test', function () { + 6| /** @var array */ + ➜ 7| $typeArray = [1, 2, 3, '1']; + 8| + 9| expect($typeArray)->toBeArray(); + 10| }); +``` diff --git a/docs/production/cache-commands.md b/docs/production/cache-commands.md new file mode 100644 index 0000000..226f476 --- /dev/null +++ b/docs/production/cache-commands.md @@ -0,0 +1,139 @@ +# CLI Commands Reference + +TypePHP provides a CLI runner binary (`vendor/bin/typephp`) with colon-style commands (`cache:clear`, `cache:warm`, `cache:rebuild`, `config:init`) for managing AST transformation caches and configuration. + +--- + +## Configuration Initializer (`config:init`) + +Generate a default `typephp.php` configuration file populated with documented settings in your project root directory: + +```bash +vendor/bin/typephp config:init +``` + +### Terminal Output +``` + TYPEPHP Configuration Initializer + + ✓ Created "typephp.php" in project root directory. +``` + +If `typephp.php` already exists, `config:init` preserves your existing file without overwriting it. + +--- + +## Clearing Cache (`cache:clear`) + +Wipe all transformed PHP files from the `typephp-cache/` disk directory: + +```bash +vendor/bin/typephp cache:clear +``` + +### Terminal Output +``` + TYPEPHP Cache Clear + + ✓ Cleared 178 cached file(s). +``` + +Use `cache:clear` whenever you update TypePHP or change global configuration settings. + +--- + +## Warming Cache (`cache:warm`) + +Recursively scan your project directory for files matching your `typephp.php` `include` patterns and pre-transform them before opening web traffic: + +```bash +vendor/bin/typephp cache:warm +``` + +### Terminal Output +``` + TYPEPHP Cache Warm-Up + + ................................................................................ + ................................................................................ + .................. + + ✓ Cache warm-up complete + • Scanned: 178 file(s) + • Transformed: 178 file(s) + • Skipped: 0 file(s) +``` + +### Progress Indicators + +* **`.` (Dot):** A file was successfully parsed, transformed, and cached to disk. +* **`s` (Skipped):** A file is already up-to-date in cache or matches an `exclude` pattern. + +### Deployment Script Usage + +Add `cache:warm` to your CI/CD or deployment pipeline (Forge, Envoyer, GitHub Actions) so the very first production HTTP request receives instant $O(1)$ native OPCache execution speed: + +```bash +# In your deployment script: +php vendor/bin/typephp cache:warm +``` + +--- + +## Rebuilding Cache (`cache:rebuild`) + +Wipe all existing cache files and immediately pre-transform the new release files in a single atomic command: + +```bash +vendor/bin/typephp cache:rebuild +``` + +### Terminal Output +``` + TYPEPHP Cache Clear + + ✓ Cleared 178 cached file(s). + + TYPEPHP Cache Warm-Up + + ................................................................................ + ................................................................................ + .................. + + ✓ Cache warm-up complete + • Scanned: 178 file(s) + • Transformed: 178 file(s) + • Skipped: 0 file(s) +``` + +This is the recommended command for automated zero-downtime deployment scripts. + +--- + +## Help Menu (`help`) + +Display the interactive CLI runner help menu: + +```bash +vendor/bin/typephp help +``` + +### Terminal Output +``` + TYPEPHP Runtime Type Checker + + USAGE + vendor/bin/typephp + + COMMANDS + config:init Generate default typephp.php configuration file + cache:clear Clear all cached transformed files + cache:warm Pre-transform and warm up cache for included files + cache:rebuild Clear and immediately warm up cache + help Display this help menu + + EXAMPLES + vendor/bin/typephp config:init + vendor/bin/typephp index.php + vendor/bin/typephp cache:rebuild +``` diff --git a/docs/production/performance-considerations.md b/docs/production/performance-considerations.md new file mode 100644 index 0000000..7d8f6b2 --- /dev/null +++ b/docs/production/performance-considerations.md @@ -0,0 +1,145 @@ +# Performance Considerations + +TypePHP is engineered for sub-second CLI test runs and $O(1)$ memory lookups during web request execution. This document explains the internal performance architecture, OPCache interactions, JIT realities, and benchmarking guidelines. + +--- + +## Performance Architecture Overview + +TypePHP minimizes execution overhead through 4 core architectural optimizations: + +``` +[Incoming Data Check] + │ + ├── 1. Type String AST Cache ($parsedTypeNodeCache) ──► O(1) Instant AST Lookup + │ + ├── 2. Object Validation WeakMap ($validatedObjectCache) ──► O(1) Object Memoization + │ + ├── 3. Reflection Hierarchy Cache (HierarchyResolver) ──► O(1) Class Tree Lookup + │ + └── 4. OPCache Disk Cache (typephp-cache/) ──► 0.00ms Transformation Overhead +``` + +--- + +## Core In-Memory Optimizations + +### 1. Type String AST Caching (`$parsedTypeNodeCache`) + +When an inline variable assignment runs (`/** @var positive-int $age */`), TypePHP tokenizes and parses the string `'positive-int'` using PHPStan's `TypeParser` **only once per PHP process**. + +On all subsequent assignments or loop iterations, TypePHP retrieves the pre-parsed `TypeNode` directly from static RAM in $O(1)$ constant time, completely eliminating lexer and parser overhead during execution. + +### 2. Massive Array Validation Overhead ($O(N)$ Complexity) + +Validating array shapes (`array{id: int}`), sequential lists (`list`), or typed arrays (`User[]`) requires iterating every individual array element: + +* **Small to Medium Arrays (10–500 items):** Validated in microseconds with negligible CPU impact. +* **Massive Datasets (5,000–50,000+ items):** Carrying $O(N)$ iteration complexity, validating massive arrays synchronously introduces **significant CPU performance overhead**. + +> **Production Warning:** Synchronously validating massive, multi-thousand element array datasets at runtime is **NOT recommended in live production applications**. +> +> **Alternative Strategy:** For large datasets, use **Generators (`Generator`)** to validate items lazily one-by-one as you stream them, or turn off inline array checking (`inline_vars.arrays => false`) on internal methods while maintaining strict function parameter boundaries. + +### 3. Object Validation Memoization (`\WeakMap`) + +When validating large collections or arrays of objects (such as `User[]` or `list>`), re-validating identical object instances repeatedly is CPU-intensive. + +`TypeValidatorRegistry` memoizes previously validated object instances against type signatures using PHP's native `WeakMap`. +* **$O(1)$ Validation:** If an object instance has already been checked against `User`, subsequent checks on the same object return `true` instantly. +* **Zero Memory Leaks:** The moment an object instance is garbage-collected by PHP, its `WeakMap` cache entry is automatically deleted from RAM. + +### 4. In-Memory Reflection Hierarchy Caching (`HierarchyResolver`) + +Resolving complex class, interface, trait, and property hook inheritance trees requires Reflection calls. + +`HierarchyResolver` caches resolved `ReflectionClass` and `ReflectionMethod` inheritance trees in static RAM arrays (`$classHierarchyCache` and `$methodHierarchyCache`). If a class has 20 methods, its inheritance tree is inspected **exactly once**. + +--- + +## OPCache and Web Server Execution + +Understanding the difference between CLI test runs and production web server execution: + +### CLI Test Execution (Pest & PHPUnit) + +During CLI test runs, a single PHP process executes your test suite. TypePHP transforms and executes 380+ complex type-checking feature and unit tests in **~1.10 seconds** without requiring any special PHP flags or server extensions. + +### Production Web Servers (PHP-FPM, FrankenPHP, Swoole, RoadRunner) + +In production web servers, when `'cache' => true` is enabled in `typephp.php`: + +1. **Warm Cache:** Pre-transforming files via `vendor/bin/typephp cache:warm` during deployment writes transformed PHP code to disk (`typephp-cache/`). +2. **Bytecode Compilation:** PHP's **OPCache** compiles the cached file **once into bytecode in RAM**. +3. **Execution:** On all subsequent HTTP requests, PHP executes the transformed bytecode directly from OPCache RAM at native C-level speed. AST parsing runs **0 times**. + +--- + +## Real-World Benchmark Discovery: PHP 8 JIT Behavior + +During real-world benchmarking of TypePHP's test suite, we discovered an important behavioral reality regarding PHP 8's JIT (Just-In-Time) compiler: + +### Short-Lived CLI Test Runs (Pest / PHPUnit) + +Running Pest with JIT enabled in CLI (`php -d opcache.enable_cli=1 -d opcache.jit_buffer_size=128M -d opcache.jit=tracing vendor/bin/pest`) **increased execution time from 1.10s to 2.36s (over 2x slower)** compared to standard PHP CLI execution. + +**Why JIT is slower for CLI test runs:** +1. **Cold-Start Allocation Overhead:** Allocating a 128MB JIT shared memory buffer and initializing tracing on a process that finishes in ~1 second adds ~1.2 seconds of compilation overhead. +2. **Discarded Machine Code:** Because the CLI process exits immediately after 1 second, the compiled JIT machine code is discarded without ever being reused across subsequent requests. + +> **Recommendation for CLI Testing:** Run local Pest and PHPUnit test suites with standard PHP CLI execution (without `opcache.enable_cli=1` or JIT enabled) for maximum sub-second test speed. + +### Web Server Environments (PHP-FPM, FrankenPHP, Swoole) + +It remains **unconfirmed** whether enabling OPCache alone or OPCache + JIT provides a net performance speedup in production web server environments. Because PHP 8's JIT compiler is primarily designed for CPU-intensive mathematical calculations rather than I/O, array, and reflection operations, OPCache RAM bytecode caching provides the majority of execution speedup for TypePHP. + +> **Community Benchmark Call-to-Action:** +> We need your real-world feedback! If you benchmark TypePHP on staging or live application workloads (using tools like Blackfire, Xdebug, or ApacheBench), please share your performance benchmarks and feedback with the project on [GitHub Discussions](https://github.com/typephp/typephp/discussions)! + +--- + +## Benchmarking Guidelines for Your Application + +To measure the exact execution impact of TypePHP on your specific application: + +### 1. Conducting a True A/B Performance Benchmark + +To establish an exact baseline comparison between native PHP and TypePHP: + +* **Web Application A/B Benchmark (PHP-FPM / Laravel / Symfony):** + Set `TYPEPHP_DISABLE=true` in your `.env` or server environment to completely bypass TypePHP's stream wrapper during autoloading, establishing an exact baseline for native PHP web execution speed. +* **CLI Script A/B Benchmark:** + Compare running `php script.php` (standard native PHP execution without TypePHP) against `vendor/bin/typephp script.php` (TypePHP active execution). + +### 2. Warm Up the Cache First + +Always run `cache:warm` before benchmarking so file transformation time is excluded from your request benchmarks: + +```bash +vendor/bin/typephp cache:warm +``` + +### 3. Isolate Boundary vs. Internal Checks + +If you want to measure the performance impact of internal variable assignments versus function boundaries, test different `typephp.php` configurations: + +```php +// Strict Boundaries + Ultra-Fast Internal Loops +'params' => true, +'returns' => true, + +'inline_vars' => [ + 'properties' => true, + 'generics' => true, + 'callables' => true, + 'scalars' => false, // Turn off inline scalar checks for maximum loop speed + 'arrays' => false, // Turn off inline array checks for maximum loop speed + 'objects' => true, +], +``` + +### 4. Profiling with Blackfire or Xdebug + +When profiling TypePHP using Blackfire or Xdebug: +* Look at **`TypeValidatorRegistry::validate`** for execution time spent on type validation. +* Notice that **`ContractParser::parse`** and **`SpecialTypeResolver`** drop to near-zero CPU time after the first invocation due to static RAM caching! diff --git a/docs/production/production-readiness.md b/docs/production/production-readiness.md new file mode 100644 index 0000000..d2a2908 --- /dev/null +++ b/docs/production/production-readiness.md @@ -0,0 +1,127 @@ +# Production Readiness & Strategy + +This document outlines the stability status of TypePHP, production deployment strategies, and recommended safety guidelines. + +--- + +## Pre-1.0 Stability Warning + +> **Stability Warning:** TypePHP is currently in active pre-1.0 development and has not yet reached its stable `v1.0.0` release. +> +> **Do NOT use TypePHP in high-stakes, mission-critical production applications yet.** +> +> TypePHP is currently recommended for: +> * Local development environments (require-dev) +> * Pest / PHPUnit test suites +> * CI/CD build pipelines +> * Staging and QA testing servers +> * Non-critical live applications and internal web tools + +--- + +## Selective Whitelisting Strategy + +When deploying TypePHP to non-critical live applications or staging environments, use **Selective Whitelisting** in `typephp.php` rather than type-checking your entire codebase. + +Instead of including all application directories, target only specific domain modules: + +```php +// typephp.php +return [ + 'include' => [ + 'app/Domain/Billing/**', // Whitelist specific domain logic + 'app/Services/Payment/**', + ], + + 'exclude' => [ + 'vendor/**', + 'storage/**', + 'var/**', + 'cache/**', + ], +]; +``` + +### Why Selective Whitelisting Works + +1. **Zero Overhead for Unincluded Code:** Files not listed in `include` pass directly to PHP's native C-engine with zero AST parsing, zero transformation, and zero validation overhead. +2. **Targeted Guard Rails:** Your core domain services protect themselves against bad input data without adding execution overhead to simple GET endpoints or rendering logic. + +--- + +## Production Performance Optimization + +When running TypePHP in live or staging environments, apply these three performance optimizations: + +### 1. Enable Disk Caching (`cache => true`) + +Ensure disk caching is enabled in `typephp.php`: + +```php +'cache' => true, +``` + +When caching is enabled, TypePHP transforms each PHP file once and saves the pre-compiled output to disk (`typephp-cache/`). PHP's **OPCache** loads the transformed bytecode directly into RAM, meaning AST parsing runs **0 times** on subsequent HTTP requests. + +### 2. Pre-Warm Cache During Deployment (`cache:warm`) + +Run `cache:warm` (or `cache:rebuild`) in your deployment scripts before opening web traffic: + +```bash +# In your deployment pipeline: +vendor/bin/typephp cache:rebuild +``` + +This pre-transforms all included PHP files on disk, ensuring the very first HTTP request receives instant $O(1)$ execution speed. + +### 3. Disable Heavy Inline Variable Toggles + +In live environments, you can disable local internal variable assignment checks while keeping strict function parameter and return boundaries active: + +```php +'params' => true, // Keep public function parameter contracts ON +'returns' => true, // Keep public function return contracts ON + +'inline_vars' => [ + 'properties' => true, + 'generics' => true, + 'callables' => true, + 'scalars' => false, // Turn off inline scalar checks for maximum loop speed + 'arrays' => false, // Turn off inline array checks for maximum loop speed + 'objects' => true, +], +``` + +--- + +Here is the updated **Emergency Kill-Switches** section for `docs/production/production-readiness.md` with the callout note explaining the difference between the environment approach and config approach: + +--- + +## Emergency Kill-Switches + +If you ever need to disable TypePHP instantly in a live environment, you have two zero-downtime options: + +### 1. Environment Variable Kill-Switch (`TYPEPHP_DISABLE`) + +Set `TYPEPHP_DISABLE=true` in your server environment or `.env` file: + +```bash +export TYPEPHP_DISABLE=true +``` + +This prevents `StreamWrapper` from registering during Composer autoloading. + +### 2. Config Master Switch (`enabled => false`) + +Set `'enabled' => false` in `typephp.php` or dynamically at runtime: + +```php +TypePHP::setConfig(['enabled' => false]); +``` + +All runtime check methods immediately become instant no-ops and pass raw values through natively. + +> **Key Difference Between Disabling Approaches:** +> * **Environment Level (`TYPEPHP_DISABLE=true`):** Evaluated during Composer autoloading (`vendor/autoload.php`). TypePHP never boots, and the `StreamWrapper` is never registered with PHP's Zend Engine. +> * **Config Level (`'enabled' => false`):** TypePHP boots normally, but `StreamWrapper` and `RuntimeTypeChecker` act as an instant pass-through, bypassing all type checks during execution. diff --git a/package.json b/package.json new file mode 100644 index 0000000..e59c2ad --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "typephp-docs", + "private": true, + "scripts": { + "docs:dev": "vitepress dev docs", + "docs:build": "vitepress build docs", + "docs:preview": "vitepress preview docs" + }, + "devDependencies": { + "vitepress": "^1.6.3" + }, + "overrides": { + "esbuild": "^0.25.0" + } +} \ No newline at end of file diff --git a/tests/Fixtures/Oop/ChildClassInheritingTraitParent.php b/tests/Fixtures/Oop/ChildClassInheritingTraitParent.php new file mode 100644 index 0000000..979238e --- /dev/null +++ b/tests/Fixtures/Oop/ChildClassInheritingTraitParent.php @@ -0,0 +1,10 @@ + */ -// $typeArray = [1, 2, 3, '1']; -// }); +test('test', function () { + /** @var array */ + $typeArray = [1, 2, 3, '1']; + + expect($typeArray)->toBeArray(); +}); + diff --git a/tests/TypeChecking/OopInheritanceTest.php b/tests/TypeChecking/OopInheritanceTest.php index bc8f123..061d1c5 100644 --- a/tests/TypeChecking/OopInheritanceTest.php +++ b/tests/TypeChecking/OopInheritanceTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use TypePHP\Tests\Fixtures\Oop\ChildClassInheritingTraitParent; use TypePHP\Tests\Fixtures\Oop\ClassUsingTraitProperties; use TypePHP\Tests\Fixtures\Oop\ConcreteChildService; use TypePHP\Tests\Fixtures\Oop\TraitImplementation; @@ -13,12 +14,10 @@ expect($service->process(10))->toBe('item_10'); expect(fn () => $service->process(-50)) - ->toThrow(TypeError::class, 'positive-int') - ; + ->toThrow(TypeError::class, 'positive-int'); expect(fn () => $service->process(999)) - ->toThrow(TypeError::class, 'non-empty-string') - ; + ->toThrow(TypeError::class, 'non-empty-string'); }); test('inherits interface docblock contracts when method is fulfilled via Trait', function () { @@ -27,12 +26,10 @@ expect($app->execute(100))->toBe('code_100'); expect(fn () => $app->execute(-5)) - ->toThrow(TypeError::class, 'positive-int') - ; + ->toThrow(TypeError::class, 'positive-int'); expect(fn () => $app->execute(999)) - ->toThrow(TypeError::class, 'non-empty-string') - ; + ->toThrow(TypeError::class, 'non-empty-string'); }); test('inherits instance and static property @var docblocks from Traits', function () { @@ -42,14 +39,21 @@ expect($app->traitInstanceProp)->toBe(100); expect(fn () => $app->setTraitInstanceProp(-50)) - ->toThrow(TypeError::class, 'positive-int') - ; + ->toThrow(TypeError::class, 'positive-int'); ClassUsingTraitProperties::setTraitStaticProp('v2.0'); expect(ClassUsingTraitProperties::$traitStaticProp)->toBe('v2.0'); expect(fn () => ClassUsingTraitProperties::setTraitStaticProp('')) - ->toThrow(TypeError::class, 'non-empty-string') - ; + ->toThrow(TypeError::class, 'non-empty-string'); }); -}); + + test('inherits trait docblock contracts across parent-child class inheritance', function () { + $child = new ChildClassInheritingTraitParent(); + + expect($child->logMessage(10, 'boot'))->toBe('log_10_boot'); + + expect(fn () => $child->logMessage(-5, 'boot')) + ->toThrow(TypeError::class, 'positive-int'); + }); +}); \ No newline at end of file diff --git a/tests/TypeChecking/PhpAttributesCoexistenceTest.php b/tests/TypeChecking/PhpAttributesCoexistenceTest.php new file mode 100644 index 0000000..277fd05 --- /dev/null +++ b/tests/TypeChecking/PhpAttributesCoexistenceTest.php @@ -0,0 +1,61 @@ +processUser(42))->toBe('user_42'); + + expect(fn () => $fixture->processUser(-50)) + ->toThrow(TypeError::class, 'positive-int'); + + expect($fixture->executeWithDocAbove(100))->toBeTrue(); + + expect(fn () => $fixture->executeWithDocAbove(-5)) + ->toThrow(TypeError::class, 'positive-int'); + + expect(fn () => $fixture->id = -10) + ->toThrow(TypeError::class, 'positive-int'); + }); +}); \ No newline at end of file From 43a46750b0c24951ba3e330cbed72377eb120fd6 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 8 Aug 2026 17:59:36 +0800 Subject: [PATCH 09/15] Add troubleshooting and FAQ documentation; enhance index and quick start guide content --- docs/.vitepress/config.mts | 1 + docs/advanced/troubleshooting.md | 111 ++++++++++++++++++ docs/advanced/vendor-and-path-filtering.md | 4 +- docs/core-concepts/generics-and-bounds.md | 1 - docs/getting-started/quick-start.md | 39 ++++-- docs/index.md | 97 +++++++++++---- docs/production/performance-considerations.md | 12 +- 7 files changed, 224 insertions(+), 41 deletions(-) create mode 100644 docs/advanced/troubleshooting.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 82d2e75..6f66fc6 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -55,6 +55,7 @@ export default defineConfig({ { text: 'Ignore Annotations', link: '/advanced/ignore-annotations' }, { text: 'Extensions', link: '/advanced/extensions' }, { text: 'Exception Handling', link: '/advanced/exception-handling' }, + { text: 'Troubleshooting & FAQ', link: '/advanced/troubleshooting' } ] }, { diff --git a/docs/advanced/troubleshooting.md b/docs/advanced/troubleshooting.md new file mode 100644 index 0000000..fec8673 --- /dev/null +++ b/docs/advanced/troubleshooting.md @@ -0,0 +1,111 @@ +# Troubleshooting & FAQ + +This page addresses common questions, debugging techniques, and edge cases you might encounter when using TypePHP in local development, test suites, or production environments. + +--- + +## Configuration & Execution + +### Why is my file or method not being type-checked? + +If TypePHP is not enforcing contracts on a specific file or method, check the following common causes: + +1. **Path Exclusion Specificity:** Check your `typephp.php` configuration. If your file matches an `exclude` pattern (such as `vendor/**` or `storage/**`), TypePHP skips AST transformation. Remember that equal length patterns favor `exclude`. +2. **Ignore Annotations:** Check if the file header contains `@typephp-ignore-file` or if the method docblock contains `@typephp-ignore`. +3. **Disabled Inline Variable Toggles:** If an inline variable (`/** @var positive-int $x */`) is not throwing an error, verify that the corresponding toggle inside `inline_vars` in `typephp.php` is set to `true`. +4. **Stale Cache:** If you recently edited docblocks or configuration settings, your pre-transformed file might be cached on disk. Run `vendor/bin/typephp cache:clear`. + +--- + +### How do I know if TypePHP is actively transforming a file? + +You can verify that a file is being intercepted and transformed in two ways: + +1. **Intentionally Trigger an Error:** Pass an invalid argument (such as a negative integer to a `positive-int` parameter). If a `TypePHP\Exception\TypeError` is thrown, TypePHP is active. +2. **Inspect the Cache Directory:** Look inside your system temporary directory (`sys_get_temp_dir() . '/typephp-cache/'`). You will see transformed PHP files containing injected `RuntimeTypeChecker` calls. + +--- + +### How do I clear the AST cache? + +You can wipe the cache using the CLI runner: + +```bash +vendor/bin/typephp cache:clear +``` + +If you are changing configuration settings frequently during local development, you can temporarily disable disk caching in `typephp.php`: + +```php +'cache' => false, // Transforms files purely in RAM (php://memory) +``` + +--- + +## Type Enforcement & Edge Cases + +### Why didn't TypePHP catch a bad property assignment from an external file? + +TypePHP injects guard rails at the call site where assignments happen. + +* **Whitelisted Caller File:** If `Controller.php` (whitelisted) sets `$user->id = -5`, TypePHP intercepts the assignment and throws a `TypeError`. +* **Excluded Caller File:** If `LegacyVendor.php` (excluded) sets `$user->id = -5`, TypePHP does not modify `LegacyVendor.php`, so the assignment runs natively. + +**Solution:** In PHP 8.4, use **Property Hooks** (`set => $this->_id = $value`). Property hooks run *inside* the class itself, guaranteeing that assignments are validated regardless of where the call originated. + +--- + +### Why is my Pest or PHPUnit test suite running slower with JIT enabled? + +During CLI test execution, a single short-lived PHP process runs your tests. + +If you pass `-d opcache.enable_cli=1` with PHP 8 JIT enabled, PHP spends extra CPU cycles compiling JIT tracing buffers that are discarded the moment the test suite finishes a second later. + +**Solution:** Run CLI test runs with standard PHP execution (without `opcache.enable_cli=1` or JIT enabled). TypePHP executes 380+ complex type checks in sub-second time without JIT. Save JIT optimization for long-running production web servers (PHP-FPM, FrankenPHP, Swoole). + +--- + +### Why does a generic container allow any item if no annotation is provided? + +If you instantiate a generic class without an inline `@var` annotation: + +```php +$collection = new Collection(); // Unannotated generic instance +``` + +TypePHP uses **First-Use Type Inference**. It allows the first method call (such as `$collection->add(new User())`) to establish the template type `T = User`. Once established, all subsequent calls on that instance enforce `T = User`. + +If you want strict enforcement before any items are added, prebind the instance using an inline `@var` annotation: + +```php +/** @var Collection $collection */ +$collection = new Collection(); +``` + +--- + +## Frameworks & Tooling + +### Does TypePHP work with Laravel, Symfony, or WordPress? + +Yes. TypePHP boots automatically as soon as Composer's autoloader (`vendor/autoload.php`) is required. + +It works seamlessly with standard framework entry points like `public/index.php`, Laravel's `artisan`, or Symfony's `bin/console`. No special framework bundles or service providers are required. + +--- + +### How do I temporarily disable TypePHP in an emergency? + +You have two options for turning off TypePHP instantly: + +1. **Environment Level (Full Prevention):** Set `TYPEPHP_DISABLE=true` in your `.env` or server environment. This prevents TypePHP from registering its stream wrapper during Composer autoloading. +2. **Config Level (Pass-Through Mode):** Set `'enabled' => false` in `typephp.php` or call `TypePHP::setConfig(['enabled' => false])`. TypePHP will run, but all checks turn into instant no-ops. + +--- + +### Can I run TypePHP alongside static analysis tools? + +Yes, it is highly recommended. + +* **Static Analyzers (PHPStan, Psalm, Mago):** Analyze your source code at compile-time, linting docblock syntax and checking static logic in your IDE. +* **TypePHP:** Enforces those same PHPDoc contracts at runtime during dynamic execution, protecting your application against invalid database records, un-sanitized API payloads, and unexpected runtime state. diff --git a/docs/advanced/vendor-and-path-filtering.md b/docs/advanced/vendor-and-path-filtering.md index d8e7c0a..372cceb 100644 --- a/docs/advanced/vendor-and-path-filtering.md +++ b/docs/advanced/vendor-and-path-filtering.md @@ -10,7 +10,7 @@ TypePHP provides surgical control over which files are intercepted and type-chec When a file path matches both an `include` pattern and an `exclude` pattern, TypePHP determines the outcome by calculating **pattern specificity length** (`strlen($pattern)`): -$$\text{Winning Pattern} = \max(\text{Specificity Length})$$ +**`Winning Pattern = max(Specificity Length)`** * **Longer Pattern Wins:** A more specific pattern like `'vendor/acme/package/**'` (length 25) takes precedence over a broader glob like `'vendor/**'` (length 8). * **Single File Override:** A specific file path like `'src/Legacy/UnsafeFile.php'` (length 25) takes precedence over a directory glob like `'src/**'` (length 6). @@ -45,7 +45,7 @@ return [ * **`vendor/acme/domain-models/src/User.php`**: * Matches `include`: `'vendor/acme/domain-models/**'` (Length: 29) * Matches `exclude`: `'vendor/**'` (Length: 8) - * **Result:** `29 > 8` $\rightarrow$ **Included and Type-Checked!** + * **Result:** `29 > 8` -> **Included and Type-Checked!** * **`vendor/guzzlehttp/guzzle/src/Client.php`**: * Matches `include`: None diff --git a/docs/core-concepts/generics-and-bounds.md b/docs/core-concepts/generics-and-bounds.md index 6405ca4..2a70a82 100644 --- a/docs/core-concepts/generics-and-bounds.md +++ b/docs/core-concepts/generics-and-bounds.md @@ -142,7 +142,6 @@ $variances = TypePHP::getGenericVariances(object: $producer); // Returns ['T' => * **Single-Template Smart Fallback:** If a class has only 1 template parameter (e.g. `@template ItemType`), `TypePHP::getGenericType($object)` automatically returns that template's bound type without requiring you to guess whether the author named it `T`, `E`, or `ItemType`. * **Inherited Template Resolution:** Automatically resolves generic types declared on parent classes (`@extends BaseRepository`) or interfaces (`@implements ProcessorInterface`). * **First-Use Inference:** On un-annotated generic instances (`$collection = new Collection()`), `getGenericType()` returns `null` before first use, and returns the inferred type (e.g. `User`) immediately after the first method call! -``` --- diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index b9d492a..a5053ed 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -7,19 +7,44 @@ TypePHP enforces PHPDoc type contracts at runtime. Below is an overview of core > > It is highly recommended to use any static analyzer alongside TypePHP: > * **PHPStan / Psalm / Mago / Phan (Compile-Time):** Lints your PHPDoc syntax, validates complex intersection rules, and catches static type errors in your IDE before code executes. -> * **TypePHP (Runtime):** Enforces those PHPDoc contracts during actual execution, ensuring your application against invalid API payloads, database records, and dynamic runtime data or make sure the doctypes will not lie to you at runtime, this is the case when you dont use static analyzers or only allow linient level on static type checking. +> * **TypePHP (Runtime):** Enforces those PHPDoc contracts during actual execution, ensuring your application against invalid API payloads, database records, and dynamic runtime data or making sure the doctypes will not lie to you at runtime. --- -## Executing Standalone Scripts via CLI +## Execution & Framework Entry Points -Run any standalone PHP script with active runtime type enforcement using the `vendor/bin/typephp` binary: +Because TypePHP automatically integrates with Composer's autoloader (`vendor/autoload.php`), you don't always need to use the custom CLI runner. -```bash -vendor/bin/typephp index.php -``` +If your application executes through an explicit, standard entry point like a web framework's **`public/index.php`**, Laravel's **`artisan`** console, or test runners like **`vendor/bin/pest`** and **`phpunit`**, TypePHP boots naturally out of the box. + +Once booted, TypePHP transparently intercepts, transforms, and enforces types on any PHP file that is whitelisted in your `typephp.php` configuration file (`include` paths). + +*(For standalone single-file scripts without an autoloader, you can still use `vendor/bin/typephp index.php` to run them with type checking enabled).* + +--- + +## Namespace & Import Resolution + +TypePHP is fully aware of your file's namespace context and `use` import statements. You can write your docblocks using short imported names, relative names, aliased imports, or Fully Qualified Class Names (FQCN), and TypePHP will resolve them perfectly at runtime: + +```php +) to object instances using WeakMap memory tracking. - - title: Production Ready - details: Selective path whitelisting allows type-checking mission-critical domain logic in production with zero risk. - - title: PHP 8.4 Support - details: Native support for PHP 8.4 Property Hooks (get/set) and Asymmetric Visibility (public private(set)). + - title: "Zero Production Overhead" + details: "Install as a development dependency to enforce strict types during local testing and CI/CD pipelines, guaranteeing absolute zero performance cost in live production environments." + - title: "True Runtime Generics" + details: "Binds generic template types to specific object instances dynamically using native WeakMap memory tracking." + - title: "Typed Arrays & Shapes" + details: "Deeply validates sequential lists, typed class arrays, and strict associative array shape structures right out of the box." + - title: "PHP 8.4 Support" + details: "Native support for intercepting and validating PHP 8.4 Property Hooks (get/set) and Asymmetric Visibility (public private(set))." --- -## Real-World Example +## See It In Action + +TypePHP operates entirely in user-land using native PHP stream wrappers and AST transformations. Because it is written in pure PHP and requires no C-extensions or FFI, you can drop it into any project effortlessly. It parses your standard PHPDoc annotations and enforces them the moment your code runs. + +### True Runtime Generics +Define generic templates and TypePHP will track their state in memory per object instance: ```php -use App\Models\User; -use TypePHP\Tests\Fixtures\Generics\Collection; +/** + * @template T + */ +class Collection +{ + /** @param T $item */ + public function add(mixed $item): void { /* ... */ } +} + +// Prebind T = User to this specific object instance +/** @var Collection $users */ +$users = new Collection(); + +$users->add(new User('Alice')); // Valid + +$users->add(new Product('SKU-100')); +// TypeError: Argument $item (template T = User) must be of type User, Product given +``` + +### Array Shapes & Typed Arrays +Enforce strict associative array structures and collections of specific objects: +```php /** - * Enforce parameter and return contracts directly in PHPDoc annotations - * - * @param Collection $users - * @param array{status: 'active'|'pending', count: positive-int} $options - * @return list + * @param array{status: 'active'|'pending', tags: list} $options + * @param User[] $collaborators */ -function processUserBatch(Collection $users, array $options): array +function processBatch(array $options, array $collaborators): void { - /** @var array $typeArray */ - $typeArray = [1, 2, 3, '1']; + // ... +} + +processBatch( + options: ['status' => 'active', 'tags' => ['php', 'types']], + collaborators: [new User(), new User()] +); // Valid + +processBatch( + options: ['status' => 'archived', 'tags' => ['php']], + collaborators: [] +); +// TypeError: Argument $options['status'] must be of type ('active' | 'pending') +``` + +### Scalar Refinements & Function Boundaries +Catch invalid parameters before your function executes, and invalid return values before they leak out: - return [10, 20, 30]; +```php +/** + * @param positive-int $id + * @return non-empty-string + */ +function generateUserToken(int $id): string +{ + return ""; // TypeError: Return value must be of type non-empty-string } + +generateUserToken(-5); +// TypeError: Argument $id must be of type positive-int, negative int (-5) given ``` --- @@ -52,7 +99,7 @@ function processUserBatch(Collection $users, array $options): array TypePHP injects single-line guard rails without shifting your source file line numbers. -When a type contract fails, framework error handlers and test runners (like Pest, PHPUnit, and Whoops) point **directly to the exact line number** where the invalid assignment or argument occurred: +When an inline variable or type contract fails, framework error handlers and test runners (like Pest, PHPUnit, and Whoops) point **directly to the exact line number** where the invalid assignment or argument occurred in your application code: ``` FAILED Tests\SomeTest > test diff --git a/docs/production/performance-considerations.md b/docs/production/performance-considerations.md index 7d8f6b2..c059981 100644 --- a/docs/production/performance-considerations.md +++ b/docs/production/performance-considerations.md @@ -1,6 +1,6 @@ # Performance Considerations -TypePHP is engineered for sub-second CLI test runs and $O(1)$ memory lookups during web request execution. This document explains the internal performance architecture, OPCache interactions, JIT realities, and benchmarking guidelines. +TypePHP is engineered for sub-second CLI test runs and O(1) memory lookups during web request execution. This document explains the internal performance architecture, OPCache interactions, JIT realities, and benchmarking guidelines. --- @@ -28,14 +28,14 @@ TypePHP minimizes execution overhead through 4 core architectural optimizations: When an inline variable assignment runs (`/** @var positive-int $age */`), TypePHP tokenizes and parses the string `'positive-int'` using PHPStan's `TypeParser` **only once per PHP process**. -On all subsequent assignments or loop iterations, TypePHP retrieves the pre-parsed `TypeNode` directly from static RAM in $O(1)$ constant time, completely eliminating lexer and parser overhead during execution. +On all subsequent assignments or loop iterations, TypePHP retrieves the pre-parsed `TypeNode` directly from static RAM in O(1) constant time, completely eliminating lexer and parser overhead during execution. -### 2. Massive Array Validation Overhead ($O(N)$ Complexity) +### 2. Massive Array Validation Overhead (O(N) Complexity) Validating array shapes (`array{id: int}`), sequential lists (`list`), or typed arrays (`User[]`) requires iterating every individual array element: * **Small to Medium Arrays (10–500 items):** Validated in microseconds with negligible CPU impact. -* **Massive Datasets (5,000–50,000+ items):** Carrying $O(N)$ iteration complexity, validating massive arrays synchronously introduces **significant CPU performance overhead**. +* **Massive Datasets (5,000–50,000+ items):** Carrying O(N) iteration complexity, validating massive arrays synchronously introduces **significant CPU performance overhead**. > **Production Warning:** Synchronously validating massive, multi-thousand element array datasets at runtime is **NOT recommended in live production applications**. > @@ -46,7 +46,7 @@ Validating array shapes (`array{id: int}`), sequential lists (`list`), or typ When validating large collections or arrays of objects (such as `User[]` or `list>`), re-validating identical object instances repeatedly is CPU-intensive. `TypeValidatorRegistry` memoizes previously validated object instances against type signatures using PHP's native `WeakMap`. -* **$O(1)$ Validation:** If an object instance has already been checked against `User`, subsequent checks on the same object return `true` instantly. +* **O(1) Validation:** If an object instance has already been checked against `User`, subsequent checks on the same object return `true` instantly. * **Zero Memory Leaks:** The moment an object instance is garbage-collected by PHP, its `WeakMap` cache entry is automatically deleted from RAM. ### 4. In-Memory Reflection Hierarchy Caching (`HierarchyResolver`) @@ -142,4 +142,4 @@ If you want to measure the performance impact of internal variable assignments v When profiling TypePHP using Blackfire or Xdebug: * Look at **`TypeValidatorRegistry::validate`** for execution time spent on type validation. -* Notice that **`ContractParser::parse`** and **`SpecialTypeResolver`** drop to near-zero CPU time after the first invocation due to static RAM caching! +* Notice that **`ContractParser::parse`** and **`SpecialTypeResolver`** drop to near-zero CPU time after the first invocation due to static RAM caching! \ No newline at end of file From 0423fa145d5b2d46f45e101d2e20ed5330a832a1 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 8 Aug 2026 18:35:13 +0800 Subject: [PATCH 10/15] satisfy all max level phpstan checks --- internals/test-clone-generics.php | 18 ++-- internals/test-generator-complex.php | 32 +++++--- internals/test-reified.php | 82 +++++++++++++------ src/Command/CommandRunner.php | 14 ++-- src/Command/ConfigInitCommand.php | 8 +- src/Command/HelpCommand.php | 20 ++--- src/Contract/ContractParser.php | 9 +- src/Contract/DocblockExtractor.php | 1 + src/Contract/FileFilter.php | 18 ++-- src/Contract/HierarchyResolver.php | 12 ++- src/Internal/CacheManager.php | 15 ++-- src/Internal/Checker/InlineChecker.php | 21 ++--- src/Internal/Checker/ParamChecker.php | 2 +- src/Internal/Checker/ReturnChecker.php | 2 +- src/Internal/ContractVisitor.php | 28 ++++--- src/Internal/RuntimeTypeChecker.php | 6 +- src/Internal/StreamWrapper.php | 15 +++- .../Visitor/FunctionContractInjector.php | 2 +- src/Internal/Visitor/PropertyHookInjector.php | 8 +- src/Resolver/SpecialTypeResolver.php | 12 +-- src/Resolver/TemplateManager.php | 42 +++++----- src/TypePHP.php | 9 +- src/Validator/GenericValidator.php | 1 - src/Validator/IdentifierValidator.php | 4 +- src/Wrapper/IterableWrapper.php | 5 +- tests/Command/CommandRunnerTest.php | 11 ++- tests/Fixtures/Generics/GenericBox.php | 2 +- .../Generics/GenericBoxWithMagicClone.php | 2 +- .../Oop/ChildClassInheritingTraitParent.php | 2 +- tests/Fixtures/Oop/DeepTraitWithContracts.php | 3 +- tests/Fixtures/Oop/ParentClassWithTrait.php | 2 +- tests/SomeTest.php | 11 ++- .../TypeChecking/CloneGenericInstanceTest.php | 17 ++-- tests/TypeChecking/OopInheritanceTest.php | 20 +++-- .../PhpAttributesCoexistenceTest.php | 16 ++-- tests/Unit/TypePHPTest.php | 31 ++++--- 36 files changed, 301 insertions(+), 202 deletions(-) diff --git a/internals/test-clone-generics.php b/internals/test-clone-generics.php index c134a4f..13fa4f0 100644 --- a/internals/test-clone-generics.php +++ b/internals/test-clone-generics.php @@ -2,9 +2,15 @@ declare(strict_types=1); -class Animal {} -class Dog extends Animal {} -class Car {} +class Animal +{ +} +class Dog extends Animal +{ +} +class Car +{ +} /** * 1. Standard Generic Class (No __clone) @@ -68,7 +74,7 @@ public function __clone(): void echo " ❌ FAIL: Standard cloned box accepted Car! T = Dog was lost!\n"; } catch (TypeError $e) { echo " ✅ SUCCESS: Caught expected TypeError!\n"; - echo " Message: " . $e->getMessage() . "\n\n"; + echo ' Message: ' . $e->getMessage() . "\n\n"; } // TEST 2: Class with Magic __clone() @@ -83,5 +89,5 @@ public function __clone(): void echo " ❌ FAIL: Cloned box with __clone() accepted Car! T = Dog was lost!\n"; } catch (TypeError $e) { echo " ✅ SUCCESS: Caught expected TypeError!\n"; - echo " Message: " . $e->getMessage() . "\n"; -} \ No newline at end of file + echo ' Message: ' . $e->getMessage() . "\n"; +} diff --git a/internals/test-generator-complex.php b/internals/test-generator-complex.php index ded9764..e19dc4f 100644 --- a/internals/test-generator-complex.php +++ b/internals/test-generator-complex.php @@ -3,17 +3,27 @@ declare(strict_types=1); // Standalone Domain Classes -class Animal {} -class Dog extends Animal {} -class Car {} +class Animal +{ +} +class Dog extends Animal +{ +} +class Car +{ +} /** * @template T */ class Producer { - /** @param T $item */ - public function __construct(public mixed $item) {} + /** + * @param T $item + */ + public function __construct(public mixed $item) + { + } } /** @@ -44,12 +54,12 @@ function testGenericGenerator(): Generator echo "1. Testing Generator Yielding Array Shapes:\n"; $gen1 = testShapeGenerator(); $firstItem = $gen1->current(); -echo " ✅ Success: Yielded valid shape: " . json_encode($firstItem) . "\n\n"; +echo ' ✅ Success: Yielded valid shape: ' . json_encode($firstItem) . "\n\n"; // 2. Sending Valid Shape into Generator (TSend) echo "2. Testing \$gen->send() with Valid TSend Shape ('action' => 'approve'):\n"; $secondItem = $gen1->send(['action' => 'approve']); -echo " ✅ Success: Yielded second shape: " . json_encode($secondItem) . "\n\n"; +echo ' ✅ Success: Yielded second shape: ' . json_encode($secondItem) . "\n\n"; // 3. Sending Invalid Shape into Generator (TSend) echo "3. Testing \$gen->send() with Invalid TSend Shape ('action' => 'delete'):\n"; @@ -61,7 +71,7 @@ function testGenericGenerator(): Generator echo " ❌ FAIL: Generator accepted invalid TSend action 'delete'!\n"; } catch (TypeError $e) { echo " ✅ SUCCESS: Caught expected TypeError on TSend!\n"; - echo " Message: " . $e->getMessage() . "\n\n"; + echo ' Message: ' . $e->getMessage() . "\n\n"; } // 4. Yielding Invalid Generic Object @@ -70,10 +80,10 @@ function testGenericGenerator(): Generator try { foreach ($gen3 as $key => $producer) { - echo " Yielded item #{$key}: " . get_class($producer->item) . "\n"; + echo " Yielded item #{$key}: " . \get_class($producer->item) . "\n"; } echo " ❌ FAIL: Generator yielded Producer without throwing TypeError!\n"; } catch (TypeError $e) { echo " ✅ SUCCESS: Caught expected TypeError on yield!\n"; - echo " Message: " . $e->getMessage() . "\n"; -} \ No newline at end of file + echo ' Message: ' . $e->getMessage() . "\n"; +} diff --git a/internals/test-reified.php b/internals/test-reified.php index b04d57e..7871240 100644 --- a/internals/test-reified.php +++ b/internals/test-reified.php @@ -4,9 +4,24 @@ use TypePHP\TypePHP; -class User { public function __construct(public string $name) {} } -class Product { public function __construct(public string $sku) {} } -class Order { public function __construct(public int $id) {} } +class User +{ + public function __construct(public string $name) + { + } +} +class Product +{ + public function __construct(public string $sku) + { + } +} +class Order +{ + public function __construct(public int $id) + { + } +} /** * 1. Standard Generic Class (@template T) @@ -15,11 +30,18 @@ class Order { public function __construct(public int $id) {} } */ class Collection { - /** @var array */ + /** + * @var array + */ public array $items = []; - /** @param T $item */ - public function add(mixed $item): void { $this->items[] = $item; } + /** + * @param T $item + */ + public function add(mixed $item): void + { + $this->items[] = $item; + } } /** @@ -29,7 +51,9 @@ public function add(mixed $item): void { $this->items[] = $item; } */ class Box { - /** @var ItemType */ + /** + * @var ItemType + */ public mixed $item = null; } @@ -41,7 +65,9 @@ class Box */ class Dictionary { - /** @var array */ + /** + * @var array + */ public array $map = []; } @@ -52,14 +78,20 @@ class Dictionary */ abstract class BaseRepository { - /** @param T $entity */ - public function save(mixed $entity): void {} + /** + * @param T $entity + */ + public function save(mixed $entity): void + { + } } /** * @extends BaseRepository */ -class UserRepository extends BaseRepository {} +class UserRepository extends BaseRepository +{ +} echo "=== Testing Reified Generics API (TypePHP::getGenericType) ===\n\n"; @@ -71,44 +103,44 @@ class UserRepository extends BaseRepository {} /** @var Collection $products */ $products = new Collection(); -echo " User Collection T: " . TypePHP::getGenericType($users) . "\n"; -echo " Product Collection T: " . TypePHP::getGenericType($products) . "\n"; -echo " All Types Array: " . json_encode(TypePHP::getGenericTypes($users)) . "\n\n"; +echo ' User Collection T: ' . TypePHP::getGenericType($users) . "\n"; +echo ' Product Collection T: ' . TypePHP::getGenericType($products) . "\n"; +echo ' All Types Array: ' . json_encode(TypePHP::getGenericTypes($users)) . "\n\n"; // Scenario 2: Custom Template Parameter Name (@template ItemType) echo "2. Custom Template Parameter Name (@template ItemType):\n"; /** @var Box $orderBox */ $orderBox = new Box(); -echo " Smart Fallback Type: " . TypePHP::getGenericType($orderBox) . "\n"; +echo ' Smart Fallback Type: ' . TypePHP::getGenericType($orderBox) . "\n"; echo " Explicit 'ItemType': " . TypePHP::getGenericType($orderBox, 'ItemType') . "\n"; -echo " All Types Array: " . json_encode(TypePHP::getGenericTypes($orderBox)) . "\n\n"; +echo ' All Types Array: ' . json_encode(TypePHP::getGenericTypes($orderBox)) . "\n\n"; // Scenario 3: Multiple Template Parameters (@template K, @template V) echo "3. Multiple Template Parameters (@template K, @template V):\n"; /** @var Dictionary $catalog */ $catalog = new Dictionary(); -echo " Key Template K: " . TypePHP::getGenericType($catalog, 'K') . "\n"; -echo " Value Template V: " . TypePHP::getGenericType($catalog, 'V') . "\n"; -echo " All Types Array: " . json_encode(TypePHP::getGenericTypes($catalog)) . "\n\n"; +echo ' Key Template K: ' . TypePHP::getGenericType($catalog, 'K') . "\n"; +echo ' Value Template V: ' . TypePHP::getGenericType($catalog, 'V') . "\n"; +echo ' All Types Array: ' . json_encode(TypePHP::getGenericTypes($catalog)) . "\n\n"; // Scenario 4: Inherited Generics via @extends echo "4. Inherited Generic Class (@extends BaseRepository):\n"; $userRepo = new UserRepository(); -echo " Inherited Repo T: " . TypePHP::getGenericType($userRepo) . "\n"; -echo " All Types Array: " . json_encode(TypePHP::getGenericTypes($userRepo)) . "\n\n"; +echo ' Inherited Repo T: ' . TypePHP::getGenericType($userRepo) . "\n"; +echo ' All Types Array: ' . json_encode(TypePHP::getGenericTypes($userRepo)) . "\n\n"; // Scenario 5: Unannotated Generic Instance (Before First Use) echo "5. Unannotated Generic Instance (Before First Use):\n"; $mystery = new Collection(); -echo " Unbound Type: " . (TypePHP::getGenericType($mystery) ?? 'null') . "\n"; -echo " All Types Array: " . json_encode(TypePHP::getGenericTypes($mystery)) . "\n\n"; +echo ' Unbound Type: ' . (TypePHP::getGenericType($mystery) ?? 'null') . "\n"; +echo ' All Types Array: ' . json_encode(TypePHP::getGenericTypes($mystery)) . "\n\n"; // Scenario 6: First-Use Type Inference echo "6. First-Use Type Inference (After First Method Call):\n"; $mystery->add(new User('Bob')); // First method call infers T = User! -echo " Inferred Type T: " . TypePHP::getGenericType($mystery) . "\n"; -echo " All Types Array: " . json_encode(TypePHP::getGenericTypes($mystery)) . "\n"; \ No newline at end of file +echo ' Inferred Type T: ' . TypePHP::getGenericType($mystery) . "\n"; +echo ' All Types Array: ' . json_encode(TypePHP::getGenericTypes($mystery)) . "\n"; diff --git a/src/Command/CommandRunner.php b/src/Command/CommandRunner.php index 71b9d3f..e7ab1fb 100644 --- a/src/Command/CommandRunner.php +++ b/src/Command/CommandRunner.php @@ -15,28 +15,28 @@ 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); + $showHelp = \in_array('help', $args, true) || \in_array('typephp:help', $args, true) || \in_array('--help', $args, true) || \in_array('-h', $args, true); - if ($showHelp || empty($args)) { + if ($showHelp || $args === []) { return (new HelpCommand())->execute($args, $outputStream, $errorStream); } - if (in_array('config:init', $args, true) || in_array('init', $args, true)) { + if (\in_array('config:init', $args, true) || \in_array('init', $args, true)) { return (new ConfigInitCommand())->execute($args, $outputStream, $errorStream); } - if (in_array('cache:rebuild', $args, true)) { + if (\in_array('cache:rebuild', $args, true)) { return (new CacheRebuildCommand())->execute($args, $outputStream, $errorStream); } - if (in_array('cache:clear', $args, true)) { + if (\in_array('cache:clear', $args, true)) { return (new CacheClearCommand())->execute($args, $outputStream, $errorStream); } - if (in_array('cache:warm', $args, true)) { + if (\in_array('cache:warm', $args, true)) { return (new CacheWarmCommand())->execute($args, $outputStream, $errorStream); } return (new RunCommand())->execute($args, $outputStream, $errorStream); } -} \ No newline at end of file +} diff --git a/src/Command/ConfigInitCommand.php b/src/Command/ConfigInitCommand.php index fa2072a..bee88d3 100644 --- a/src/Command/ConfigInitCommand.php +++ b/src/Command/ConfigInitCommand.php @@ -12,10 +12,10 @@ public function execute(array $args, $outputStream = STDOUT, $errorStream = STDE $cwd = getcwd(); $targetFile = ($cwd !== false ? $cwd : '.') . '/typephp.php'; - fwrite($outputStream, "\n " . $c(' TYPEPHP ', 'badge') . " " . $c('Configuration Initializer', 'bold') . "\n\n"); + fwrite($outputStream, "\n " . $c(' TYPEPHP ', 'badge') . ' ' . $c('Configuration Initializer', 'bold') . "\n\n"); if (file_exists($targetFile)) { - fwrite($outputStream, " " . $c('•', 'cyan') . " Configuration file " . $c('"typephp.php"', 'bold') . " already exists.\n\n"); + fwrite($outputStream, ' ' . $c('•', 'cyan') . ' Configuration file ' . $c('"typephp.php"', 'bold') . " already exists.\n\n"); return 0; } @@ -23,7 +23,7 @@ public function execute(array $args, $outputStream = STDOUT, $errorStream = STDE $template = self::getTemplate(); file_put_contents($targetFile, $template); - fwrite($outputStream, " " . $c('✓', 'green') . " Created " . $c('"typephp.php"', 'bold') . " in project root directory.\n\n"); + fwrite($outputStream, ' ' . $c('✓', 'green') . ' Created ' . $c('"typephp.php"', 'bold') . " in project root directory.\n\n"); return 0; } @@ -156,4 +156,4 @@ private static function getTemplate(): string ]; PHP; } -} \ No newline at end of file +} diff --git a/src/Command/HelpCommand.php b/src/Command/HelpCommand.php index dace295..56d466e 100644 --- a/src/Command/HelpCommand.php +++ b/src/Command/HelpCommand.php @@ -10,20 +10,20 @@ public function execute(array $args, $outputStream = STDOUT, $errorStream = STDE { $c = [CliFormatter::class, 'color']; - fwrite($outputStream, "\n " . $c(' TYPEPHP ', 'badge_green') . " " . $c('Runtime Type Checker', 'bold') . "\n\n"); - fwrite($outputStream, " " . $c('USAGE', 'yellow') . "\n"); + fwrite($outputStream, "\n " . $c(' TYPEPHP ', 'badge_green') . ' ' . $c('Runtime Type Checker', 'bold') . "\n\n"); + fwrite($outputStream, ' ' . $c('USAGE', 'yellow') . "\n"); fwrite($outputStream, " vendor/bin/typephp \n\n"); - fwrite($outputStream, " " . $c('COMMANDS', 'yellow') . "\n"); - fwrite($outputStream, " " . $c('config:init', 'green') . " Generate default typephp.php configuration file\n"); - fwrite($outputStream, " " . $c('cache:clear', 'green') . " Clear all cached transformed files\n"); - fwrite($outputStream, " " . $c('cache:warm', 'green') . " Pre-transform and warm up cache for included files\n"); - fwrite($outputStream, " " . $c('cache:rebuild', 'green') . " Clear and immediately warm up cache\n"); - fwrite($outputStream, " " . $c('help', 'green') . " Display this help menu\n\n"); - fwrite($outputStream, " " . $c('EXAMPLES', 'yellow') . "\n"); + fwrite($outputStream, ' ' . $c('COMMANDS', 'yellow') . "\n"); + fwrite($outputStream, ' ' . $c('config:init', 'green') . " Generate default typephp.php configuration file\n"); + fwrite($outputStream, ' ' . $c('cache:clear', 'green') . " Clear all cached transformed files\n"); + fwrite($outputStream, ' ' . $c('cache:warm', 'green') . " Pre-transform and warm up cache for included files\n"); + fwrite($outputStream, ' ' . $c('cache:rebuild', 'green') . " Clear and immediately warm up cache\n"); + fwrite($outputStream, ' ' . $c('help', 'green') . " Display this help menu\n\n"); + fwrite($outputStream, ' ' . $c('EXAMPLES', 'yellow') . "\n"); fwrite($outputStream, " vendor/bin/typephp config:init\n"); fwrite($outputStream, " vendor/bin/typephp index.php\n"); fwrite($outputStream, " vendor/bin/typephp cache:rebuild\n\n"); return 0; } -} \ No newline at end of file +} diff --git a/src/Contract/ContractParser.php b/src/Contract/ContractParser.php index c91e007..dac87f6 100644 --- a/src/Contract/ContractParser.php +++ b/src/Contract/ContractParser.php @@ -114,7 +114,7 @@ public static function parseProperty(string $className, string $propertyName): ? } // Skip property type checks if docblock contains @typephp-ignore - $shouldRespectIgnore = (Config::get()['respect_ignore_tags'] ?? true); + $shouldRespectIgnore = (bool) (Config::get()['respect_ignore_tags'] ?? true); if ($shouldRespectIgnore && (str_contains($doc, '@typephp-ignore') || str_contains($doc, '@typephp-disable'))) { return self::$propertyCache[$cacheKey] = null; } @@ -230,6 +230,7 @@ private static function parseFunction(\ReflectionFunction $ref): array /** * Resolves class-level docblocks (templates and aliases) up the class inheritance chain. * + * @param \ReflectionClass $declaringClass * @param array $templates * @param array $aliases */ @@ -238,7 +239,8 @@ private static function parseClassLevelDocs(\ReflectionClass $declaringClass, ar $classHierarchy = HierarchyResolver::getClassHierarchy($declaringClass); foreach ($classHierarchy as $hierClass) { - if (FileFilter::isFileExcluded($hierClass->getFileName())) { + $fileName = $hierClass->getFileName(); + if (FileFilter::isFileExcluded($fileName !== false ? $fileName : null)) { continue; } @@ -283,7 +285,8 @@ private static function parseMethodHierarchyDocs( foreach ($hierarchy as $hierRef) { $isOriginal = ($hierRef === $ref); - if (! $isOriginal && FileFilter::isFileExcluded($hierRef->getFileName())) { + $fileName = $hierRef->getFileName(); + if (! $isOriginal && FileFilter::isFileExcluded($fileName !== false ? $fileName : null)) { continue; } diff --git a/src/Contract/DocblockExtractor.php b/src/Contract/DocblockExtractor.php index b1b64e1..31b5d41 100644 --- a/src/Contract/DocblockExtractor.php +++ b/src/Contract/DocblockExtractor.php @@ -106,6 +106,7 @@ public static function extractTypeFromPropertyDoc(string $doc, string $propName) * Extracts local and imported type aliases (@phpstan-type and @phpstan-import-type) from a PHPDoc node. * * @param array $aliases + * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref */ public static function extractAliases( PhpDocNode $phpDocNode, diff --git a/src/Contract/FileFilter.php b/src/Contract/FileFilter.php index f9f3401..8f9da7b 100644 --- a/src/Contract/FileFilter.php +++ b/src/Contract/FileFilter.php @@ -15,7 +15,7 @@ final class FileFilter * Determines whether a given file path is excluded from contract inheritance. * Non-PHP files and excluded paths return true. */ - public static function isFileExcluded(?string $fileName): bool + public static function isFileExcluded(string|false|null $fileName): bool { if ($fileName === null || $fileName === false || $fileName === '') { return false; @@ -29,7 +29,9 @@ public static function isFileExcluded(?string $fileName): bool } $config = Config::get(); + /** @var array $includes */ $includes = \is_array($config['include'] ?? null) ? $config['include'] : ['**']; + /** @var array $excludes */ $excludes = \is_array($config['exclude'] ?? null) ? $config['exclude'] : ['vendor/**', 'storage/**', 'var/**', 'cache/**']; $cwd = getcwd(); @@ -37,17 +39,23 @@ public static function isFileExcluded(?string $fileName): bool $longestIncludeMatch = 0; foreach ($includes as $pattern) { - $regex = self::compileGlobToRegex((string) $pattern, $baseDir); + if (! \is_string($pattern)) { + continue; + } + $regex = self::compileGlobToRegex($pattern, $baseDir); if (preg_match($regex, $normalizedPath) === 1) { - $longestIncludeMatch = max($longestIncludeMatch, \strlen(trim((string) $pattern))); + $longestIncludeMatch = max($longestIncludeMatch, \strlen(trim($pattern))); } } $longestExcludeMatch = 0; foreach ($excludes as $pattern) { - $regex = self::compileGlobToRegex((string) $pattern, $baseDir); + if (! \is_string($pattern)) { + continue; + } + $regex = self::compileGlobToRegex($pattern, $baseDir); if (preg_match($regex, $normalizedPath) === 1) { - $longestExcludeMatch = max($longestExcludeMatch, \strlen(trim((string) $pattern))); + $longestExcludeMatch = max($longestExcludeMatch, \strlen(trim($pattern))); } } diff --git a/src/Contract/HierarchyResolver.php b/src/Contract/HierarchyResolver.php index c3b1d51..8cd8080 100644 --- a/src/Contract/HierarchyResolver.php +++ b/src/Contract/HierarchyResolver.php @@ -19,7 +19,7 @@ final class HierarchyResolver /** * In-memory cache for resolved ReflectionClass hierarchy arrays. * - * @var array> + * @var array>> */ private static array $classHierarchyCache = []; @@ -55,11 +55,7 @@ public static function getMethodHierarchy(\ReflectionMethod $ref): array $methodName = $ref->getName(); $targetClassName = $ref->class; - try { - $targetClass = new \ReflectionClass($targetClassName); - } catch (\ReflectionException $e) { - $targetClass = $ref->getDeclaringClass(); - } + $targetClass = new \ReflectionClass($targetClassName); $parent = $targetClass->getParentClass(); while ($parent !== false) { @@ -93,7 +89,9 @@ public static function getMethodHierarchy(\ReflectionMethod $ref): array * 3. Interfaces: Collects all implemented interfaces. * 4. Traits: Collects all used traits. * - * @return array + * @param \ReflectionClass $ref + * + * @return array> */ public static function getClassHierarchy(\ReflectionClass $ref): array { diff --git a/src/Internal/CacheManager.php b/src/Internal/CacheManager.php index e3aa155..3f243a0 100644 --- a/src/Internal/CacheManager.php +++ b/src/Internal/CacheManager.php @@ -14,7 +14,7 @@ final class CacheManager /** * Cache version prefix string. Bump this whenever AST printer/transformation rules change. */ - public const VERSION_PREFIX = 'v38_'; + public const string VERSION_PREFIX = 'v0.1_'; /** * Returns the absolute path to the cache directory. @@ -98,7 +98,7 @@ public static function clear(): int public static function warmUp(?callable $progressCallback = null): array { $config = Config::get(); - if (! ($config['enabled'] ?? true)) { + if (! (bool) ($config['enabled'] ?? true)) { return ['total' => 0, 'cached' => 0, 'skipped' => 0]; } @@ -161,10 +161,13 @@ private static function findFilesToWarm(string $baseDir): array ); foreach ($iterator as $fileInfo) { - if ($fileInfo->isFile() && strtolower($fileInfo->getExtension()) === 'php') { - $path = str_replace('\\', '/', $fileInfo->getRealPath()); - if (! FileFilter::isFileExcluded($path)) { - $files[] = $path; + if ($fileInfo instanceof \SplFileInfo && $fileInfo->isFile() && strtolower($fileInfo->getExtension()) === 'php') { + $realPath = $fileInfo->getRealPath(); + if ($realPath !== false) { + $path = str_replace('\\', '/', $realPath); + if (! FileFilter::isFileExcluded($path)) { + $files[] = $path; + } } } } diff --git a/src/Internal/Checker/InlineChecker.php b/src/Internal/Checker/InlineChecker.php index 5bf7a90..467c5f7 100644 --- a/src/Internal/Checker/InlineChecker.php +++ b/src/Internal/Checker/InlineChecker.php @@ -22,7 +22,6 @@ use TypePHP\Contract\ContractParser; use TypePHP\Internal\Config; use TypePHP\Internal\DocblockNormalizer; -use TypePHP\Internal\ErrorMessage; use TypePHP\Resolver\SpecialTypeResolver; use TypePHP\Resolver\TemplateManager; use TypePHP\Resolver\TemplateSubstitutor; @@ -88,9 +87,7 @@ public static function checkVariable(mixed $value, string $typeString, string $v return $err; } } catch (\Throwable $e) { - if ($e instanceof ErrorMessage) { - return $e; - } + // Silently ignore unexpected execution exceptions } return $value; @@ -135,11 +132,13 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string if (\count($boundTemplates) > 0 || \count($declaredTemplates) > 0) { $typeNode = TemplateSubstitutor::substitute($typeNode, $boundTemplates, $declaredTemplates); - try { - $refClass = new \ReflectionClass($className); - $typeNode = SpecialTypeResolver::resolve($typeNode, $refClass); - } catch (\ReflectionException $e) { - // Silently continue if reflection fails + if (class_exists($className) || interface_exists($className) || trait_exists($className)) { + try { + $refClass = new \ReflectionClass($className); + $typeNode = SpecialTypeResolver::resolve($typeNode, $refClass); + } catch (\ReflectionException $e) { + // Silently continue if reflection fails + } } } } @@ -150,9 +149,7 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string return $err; } } catch (\Throwable $e) { - if ($e instanceof ErrorMessage) { - return $e; - } + // Silently ignore unexpected execution exceptions } return $value; diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index 6b20a09..1668ed1 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -29,7 +29,7 @@ final class ParamChecker */ public static function checkParams(string $function, array $vars, ?object $thisObj, TypeValidatorRegistry $registry): ?ErrorMessage { - if (! (Config::get()['params'] ?? true)) { + if (! (bool) (Config::get()['params'] ?? true)) { return null; } diff --git a/src/Internal/Checker/ReturnChecker.php b/src/Internal/Checker/ReturnChecker.php index de20a7d..8efad22 100644 --- a/src/Internal/Checker/ReturnChecker.php +++ b/src/Internal/Checker/ReturnChecker.php @@ -26,7 +26,7 @@ final class ReturnChecker */ public static function checkReturn(string $function, mixed $value, ?object $thisObj, array $vars, TypeValidatorRegistry $registry, callable $wrapIterableCallback): mixed { - if (! (Config::get()['returns'] ?? true)) { + if (! (bool) (Config::get()['returns'] ?? true)) { return $value; } diff --git a/src/Internal/ContractVisitor.php b/src/Internal/ContractVisitor.php index 5489010..7d67e3e 100644 --- a/src/Internal/ContractVisitor.php +++ b/src/Internal/ContractVisitor.php @@ -25,8 +25,10 @@ public function __construct() /** * Traverses and transforms AST nodes during entry. + * + * @return array|null */ - public function enterNode(Node $node): int|array|null + public function enterNode(Node $node): array|null { if ($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod @@ -85,7 +87,7 @@ public function enterNode(Node $node): int|array|null } } - if (! empty($checkStmts)) { + if ($checkStmts !== []) { return array_merge([$node], $checkStmts); } } @@ -100,7 +102,7 @@ public function enterNode(Node $node): int|array|null } if ($node instanceof Node\Expr\Assign) { - if ($node->var instanceof Node\Expr\Variable && is_string($node->var->name)) { + if ($node->var instanceof Node\Expr\Variable && \is_string($node->var->name)) { $varName = $node->var->name; $typeString = $this->scopeManager->getVarTypeFromScope($varName); @@ -118,8 +120,8 @@ public function enterNode(Node $node): int|array|null $propName = $node->var->name->toString(); $classExpr = $node->var->class; - $classArg = $classExpr instanceof Node\Name - ? new Node\Expr\ClassConstFetch($classExpr, 'class') + $classArg = $classExpr instanceof Node\Name + ? new Node\Expr\ClassConstFetch($classExpr, 'class') : $classExpr; $checkCall = NodeBuilder::createPropertyCheckCall($node->expr, $classArg, $propName); @@ -133,7 +135,7 @@ public function enterNode(Node $node): int|array|null /** * Pops the current lexical scope stack frame or replaces transformed expressions upon leaving a node. */ - public function leaveNode(Node $node): Node|int|array|null + public function leaveNode(Node $node): Node|null { if ($node instanceof Node\Expr\Clone_) { if ($node->getAttribute('typephp_wrapped') === true) { @@ -145,10 +147,12 @@ public function leaveNode(Node $node): Node|int|array|null return new Node\Expr\FuncCall( new Node\Name('\TypePHP\Internal\RuntimeTypeChecker::cloneInstance'), [ - new Node\Expr\Clone_( - new Node\Expr\FuncCall( - new Node\Name('\TypePHP\Internal\RuntimeTypeChecker::prepareClone'), - [new Node\Arg($node->expr)] + new Node\Arg( + new Node\Expr\Clone_( + new Node\Expr\FuncCall( + new Node\Name('\TypePHP\Internal\RuntimeTypeChecker::prepareClone'), + [new Node\Arg($node->expr)] + ) ) ), new Node\Arg($node->expr), @@ -188,7 +192,7 @@ private function extractDestructuringVariables(Node\Expr\List_|Node\Expr\Array_ continue; } - if ($item->value instanceof Node\Expr\Variable && is_string($item->value->name)) { + if ($item->value instanceof Node\Expr\Variable && \is_string($item->value->name)) { $vars[] = [ 'varName' => $item->value->name, 'expr' => $item->value, @@ -200,4 +204,4 @@ private function extractDestructuringVariables(Node\Expr\List_|Node\Expr\Array_ return $vars; } -} \ No newline at end of file +} diff --git a/src/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index ecef905..41cf654 100644 --- a/src/Internal/RuntimeTypeChecker.php +++ b/src/Internal/RuntimeTypeChecker.php @@ -171,7 +171,7 @@ public static function wrapIterable(string $function, string $paramName, mixed $ */ public static function prepareClone(mixed $original): mixed { - if (is_object($original)) { + if (\is_object($original)) { TemplateManager::$pendingCloneSource = $original; } @@ -183,7 +183,7 @@ public static function prepareClone(mixed $original): mixed */ public static function cloneInstance(mixed $cloned, mixed $original): mixed { - if (is_object($cloned) && is_object($original)) { + if (\is_object($cloned) && \is_object($original)) { TemplateManager::copyInstanceBindings($original, $cloned); } @@ -207,4 +207,4 @@ public static function getRegistry(): TypeValidatorRegistry { return self::$registry ??= new TypeValidatorRegistry(); } -} \ No newline at end of file +} diff --git a/src/Internal/StreamWrapper.php b/src/Internal/StreamWrapper.php index cc345e3..42e84a3 100644 --- a/src/Internal/StreamWrapper.php +++ b/src/Internal/StreamWrapper.php @@ -113,7 +113,7 @@ public static function unregister(): void public static function transformSource(string $source, string $filePath = ''): string { // Respect per-file suppression tag unless respect_ignore_tags is false - if ((Config::get()['respect_ignore_tags'] ?? true) && (str_contains($source, '@typephp-ignore-file') || str_contains($source, '@typephp-disable-file'))) { + if ((bool) (Config::get()['respect_ignore_tags'] ?? true) && (str_contains($source, '@typephp-ignore-file') || str_contains($source, '@typephp-disable-file'))) { return $source; } @@ -225,6 +225,17 @@ public function stream_lock(int $operation): bool return true; } + // CRITICAL: PHPStan's internal stub narrows flock's $operation parameter to int<0, 7>. + // At PHP runtime, valid bitwise lock operations (such as LOCK_UN = 8 or LOCK_UN | LOCK_NB = 12) + // range from 1 to 15. Passing 0 to flock() causes PHP to throw a warning ("must be one of LOCK_SH, + // LOCK_EX, or LOCK_UN"). + // + // CONSEQUENCE OF IGNORING: if it bypass PHPStan's narrow stub check here. + // RUNTIME SAFETY: The guard clause above ($operation < 1 || $operation > 15) guarantees that invalid + // operations (like 0) never reach flock(), preserving full runtime safety and compatibility + // with Pest/PHPUnit result-caching (file_put_contents). + // + // @phpstan-ignore argument.type return @flock($this->handle, $operation); } @@ -458,7 +469,7 @@ private static function isReadOnlyCall(): bool */ private static function isApplicationFile(string $path, string|false $resolvedPath): bool { - if (! (Config::get()['enabled'] ?? true)) { + if (! (bool) (Config::get()['enabled'] ?? true)) { return false; // TypePHP is globally disabled! } diff --git a/src/Internal/Visitor/FunctionContractInjector.php b/src/Internal/Visitor/FunctionContractInjector.php index a5f9777..df5f829 100644 --- a/src/Internal/Visitor/FunctionContractInjector.php +++ b/src/Internal/Visitor/FunctionContractInjector.php @@ -30,7 +30,7 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node): $docText = $doc !== null ? $doc->getText() : ''; // Per-Function/Method Suppression Tag - if ((Config::get()['respect_ignore_tags'] ?? true) && (str_contains($docText, '@typephp-ignore') || str_contains($docText, '@typephp-disable'))) { + if ((bool) (Config::get()['respect_ignore_tags'] ?? true) && (str_contains($docText, '@typephp-ignore') || str_contains($docText, '@typephp-disable'))) { return; // Skip injecting contract checks for this specific function/method! } diff --git a/src/Internal/Visitor/PropertyHookInjector.php b/src/Internal/Visitor/PropertyHookInjector.php index cd5f8d0..65e26d3 100644 --- a/src/Internal/Visitor/PropertyHookInjector.php +++ b/src/Internal/Visitor/PropertyHookInjector.php @@ -16,12 +16,12 @@ final class PropertyHookInjector { public static function process(Node\Stmt\Property $node): void { - if (empty($node->hooks)) { + if ($node->hooks === []) { return; } $doc = $node->getDocComment(); - if ((Config::get()['respect_ignore_tags'] ?? true) && $doc !== null && (str_contains($doc->getText(), '@typephp-ignore') || str_contains($doc->getText(), '@typephp-disable'))) { + if ((bool) (Config::get()['respect_ignore_tags'] ?? true) && $doc !== null && (str_contains($doc->getText(), '@typephp-ignore') || str_contains($doc->getText(), '@typephp-disable'))) { return; } @@ -38,7 +38,7 @@ public static function process(Node\Stmt\Property $node): void $hook->body = self::wrapHookReturnStatements($hook->body, $propertyName); } } elseif ($hookName === 'set') { - $paramName = ! empty($hook->params) && $hook->params[0]->var instanceof Node\Expr\Variable && \is_string($hook->params[0]->var->name) + $paramName = $hook->params !== [] && $hook->params[0]->var instanceof Node\Expr\Variable && \is_string($hook->params[0]->var->name) ? $hook->params[0]->var->name : 'value'; @@ -76,7 +76,7 @@ public function __construct(private string $propertyName) { } - public function enterNode(Node $n): ?Node + public function enterNode(Node $n): int|null { if ($n instanceof Node\Expr\Closure || $n instanceof Node\Expr\ArrowFunction || $n instanceof Node\Stmt\Function_ || $n instanceof Node\Stmt\ClassMethod) { return NodeTraverser::DONT_TRAVERSE_CHILDREN; diff --git a/src/Resolver/SpecialTypeResolver.php b/src/Resolver/SpecialTypeResolver.php index 178be7d..51d909f 100644 --- a/src/Resolver/SpecialTypeResolver.php +++ b/src/Resolver/SpecialTypeResolver.php @@ -24,7 +24,7 @@ use TypePHP\Internal\TypeFormatter; /** - * @internal Resolves special type identifiers (self, static, parent, $this, FQCNs) against Reflection or file contexts. + * @internal Resolves special type identifiers (self, static, parent, FQCNs) against Reflection or file contexts. */ final class SpecialTypeResolver { @@ -59,6 +59,8 @@ 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. + * + * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod|string $context */ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunction|\ReflectionMethod|string $context, ?object $thisObj = null): TypeNode { @@ -345,14 +347,6 @@ public static function getNamespaceFromFile(string $fileName): string /** * Resolves a short class name to its fully qualified class name (FQCN) using Reflection context. * - * Performs the following steps: - * 1. Returns built-in primitive and pseudo-type keywords directly. - * 2. Handles fully-qualified names with leading backslashes. - * 3. Validates class syntax. - * 4. Checks use imports in the declaring file. - * 5. Checks the declaring namespace. - * 6. Checks global scope. - * * @param \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref */ public static function resolveFqcn(string $name, \ReflectionClass|\ReflectionFunction|\ReflectionMethod $ref): string diff --git a/src/Resolver/TemplateManager.php b/src/Resolver/TemplateManager.php index a8fbde3..2147d6a 100644 --- a/src/Resolver/TemplateManager.php +++ b/src/Resolver/TemplateManager.php @@ -101,7 +101,7 @@ public static function getBoundTemplates(string $function, ?object $thisObj, arr } if (self::$instanceTemplateBindings === null || ! isset(self::$instanceTemplateBindings[$thisObj])) { - self::resolveInheritedTemplates($thisObj, get_class($thisObj)); + self::resolveInheritedTemplates($thisObj, \get_class($thisObj)); } if (isset(self::$instanceTemplateBindings[$thisObj])) { @@ -132,7 +132,7 @@ public static function getBoundTemplatesForInstance(object $instance): array // Auto-resolve @extends and @implements generic template mappings if (self::$instanceTemplateBindings === null || ! isset(self::$instanceTemplateBindings[$instance])) { - self::resolveInheritedTemplates($instance, get_class($instance)); + self::resolveInheritedTemplates($instance, \get_class($instance)); } if (self::$instanceTemplateBindings !== null && isset(self::$instanceTemplateBindings[$instance])) { @@ -149,7 +149,7 @@ public static function getBoundTemplatesForInstance(object $instance): array */ public static function getTemplateVariances(object $instance): array { - $className = get_class($instance); + $className = \get_class($instance); try { $ref = new \ReflectionClass($className); @@ -196,7 +196,7 @@ public static function isBound(string $function, ?object $thisObj, string $templ } if (self::$instanceTemplateBindings === null || ! isset(self::$instanceTemplateBindings[$thisObj])) { - self::resolveInheritedTemplates($thisObj, get_class($thisObj)); + self::resolveInheritedTemplates($thisObj, \get_class($thisObj)); } return isset(self::$instanceTemplateBindings[$thisObj][$templateName]); @@ -222,7 +222,7 @@ public static function getBoundType(string $function, ?object $thisObj, string $ } if (self::$instanceTemplateBindings === null || ! isset(self::$instanceTemplateBindings[$thisObj])) { - self::resolveInheritedTemplates($thisObj, get_class($thisObj)); + self::resolveInheritedTemplates($thisObj, \get_class($thisObj)); } return self::$instanceTemplateBindings[$thisObj][$templateName] ?? null; @@ -253,7 +253,7 @@ public static function bindTemplate(string $function, ?object $thisObj, string $ if (! self::hasCallFrame($function)) { self::$callStackBindings[$function][] = []; } - $lastIndex = count(self::$callStackBindings[$function]) - 1; + $lastIndex = \count(self::$callStackBindings[$function]) - 1; self::$callStackBindings[$function][$lastIndex][$templateName] = $inferredType; } } @@ -264,8 +264,8 @@ public static function bindTemplate(string $function, ?object $thisObj, string $ public static function bindInstanceFromNode(object $instance, GenericTypeNode $typeNode, string $context = '', bool $forceBind = false): ?ErrorMessage { $className = $typeNode->type->name; - if (in_array(strtolower($className), ['self', 'static', '$this'], true)) { - $className = get_class($instance); + if (\in_array(strtolower($className), ['self', 'static', '$this'], true)) { + $className = \get_class($instance); } if (! is_a($instance, $className)) { @@ -356,7 +356,7 @@ public static function bindInstanceFromNode(object $instance, GenericTypeNode $t */ public static function resolveInheritedTemplates(object $instance, string $targetClassName): void { - $actualClassName = get_class($instance); + $actualClassName = \get_class($instance); try { $ref = new \ReflectionClass($actualClassName); @@ -589,25 +589,25 @@ public static function bindInstance(object $instance, string $typeString, string */ public static function inferTypeFromValue(mixed $value): TypeNode { - if (is_int($value)) { + if (\is_int($value)) { return new IdentifierTypeNode('int'); } - if (is_string($value)) { + if (\is_string($value)) { return new IdentifierTypeNode('string'); } - if (is_float($value)) { + if (\is_float($value)) { return new IdentifierTypeNode('float'); } - if (is_bool($value)) { + if (\is_bool($value)) { return new IdentifierTypeNode('bool'); } - if (is_array($value)) { + if (\is_array($value)) { return new IdentifierTypeNode(array_is_list($value) ? 'list' : 'array'); } - if (is_object($value)) { - $className = get_class($value); - if (self::$instanceTemplateBindings !== null && isset(self::$instanceTemplateBindings[$value]) && count(self::$instanceTemplateBindings[$value]) > 0) { + if (\is_object($value)) { + $className = \get_class($value); + if (self::$instanceTemplateBindings !== null && isset(self::$instanceTemplateBindings[$value]) && \count(self::$instanceTemplateBindings[$value]) > 0) { $genericTypes = array_values(self::$instanceTemplateBindings[$value]); return new GenericTypeNode(new IdentifierTypeNode($className), $genericTypes); @@ -628,7 +628,7 @@ public static function inferTypeFromValue(mixed $value): TypeNode */ private static function hasCallFrame(string $function): bool { - return isset(self::$callStackBindings[$function]) && count(self::$callStackBindings[$function]) > 0; + return isset(self::$callStackBindings[$function]) && \count(self::$callStackBindings[$function]) > 0; } /** @@ -643,7 +643,7 @@ private static function resolveTypeNodeAst(TypeNode $n, \ReflectionClass $ref): } if ($n instanceof GenericTypeNode) { $base = new IdentifierTypeNode(SpecialTypeResolver::resolveFqcn($n->type->name, $ref)); - $generics = array_map(fn($t) => self::resolveTypeNodeAst($t, $ref), $n->genericTypes); + $generics = array_map(fn ($t) => self::resolveTypeNodeAst($t, $ref), $n->genericTypes); return new GenericTypeNode($base, $generics, $n->variances); } @@ -654,10 +654,10 @@ private static function resolveTypeNodeAst(TypeNode $n, \ReflectionClass $ref): return new NullableTypeNode(self::resolveTypeNodeAst($n->type, $ref)); } if ($n instanceof UnionTypeNode) { - return new UnionTypeNode(array_map(fn($t) => self::resolveTypeNodeAst($t, $ref), $n->types)); + return new UnionTypeNode(array_map(fn ($t) => self::resolveTypeNodeAst($t, $ref), $n->types)); } if ($n instanceof IntersectionTypeNode) { - return new IntersectionTypeNode(array_map(fn($t) => self::resolveTypeNodeAst($t, $ref), $n->types)); + return new IntersectionTypeNode(array_map(fn ($t) => self::resolveTypeNodeAst($t, $ref), $n->types)); } return $n; diff --git a/src/TypePHP.php b/src/TypePHP.php index b49fce6..0107094 100644 --- a/src/TypePHP.php +++ b/src/TypePHP.php @@ -29,12 +29,11 @@ public static function getGenericType(object $instance, ?string $templateName = return null; } - if ($templateName !== null && isset($types[$templateName])) { return $types[$templateName]; } - if (count($types) === 1) { + if (\count($types) === 1) { return reset($types); } @@ -64,7 +63,7 @@ public static function getGenericTypes(object $instance): array public static function getGenericVariance(object $instance, ?string $templateName = null): string { $variances = self::getGenericVariances($instance); - if (count($variances) === 0) { + if (\count($variances) === 0) { return 'invariant'; } @@ -72,7 +71,7 @@ public static function getGenericVariance(object $instance, ?string $templateNam return $variances[$templateName]; } - if (count($variances) === 1) { + if (\count($variances) === 1) { return reset($variances); } @@ -118,4 +117,4 @@ public static function resetConfig(): void { Config::reset(); } -} \ No newline at end of file +} diff --git a/src/Validator/GenericValidator.php b/src/Validator/GenericValidator.php index 58f5b8d..f7ebb83 100644 --- a/src/Validator/GenericValidator.php +++ b/src/Validator/GenericValidator.php @@ -202,7 +202,6 @@ private function validateObjectGeneric(mixed $value, GenericTypeNode $node, stri return ErrorFactory::createError($context . ' must be an instance of ' . $node->type->name . ', ' . TypeFormatter::formatGivenValue($value) . ' given'); } - // @phpstan-ignore-next-line return RuntimeTypeChecker::bindInstanceFromNode($value, $node, $context); } } diff --git a/src/Validator/IdentifierValidator.php b/src/Validator/IdentifierValidator.php index 4c1125d..bab3ba0 100644 --- a/src/Validator/IdentifierValidator.php +++ b/src/Validator/IdentifierValidator.php @@ -49,7 +49,7 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali 'negative-float' => (\is_float($value) || \is_int($value)) && $value < 0, 'non-positive-float' => (\is_float($value) || \is_int($value)) && $value <= 0, 'non-negative-float' => (\is_float($value) || \is_int($value)) && $value >= 0, - 'non-zero-float' => (\is_float($value) || \is_int($value)) && $value != 0, + 'non-zero-float' => (\is_float($value) || \is_int($value)) && $value !== 0 && $value !== 0.0, 'class-string' => \is_string($value) && preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff\\\\]*$/', $value) === 1 && (class_exists($value) || interface_exists($value) || trait_exists($value) || enum_exists($value)), @@ -80,4 +80,4 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali return null; } -} \ No newline at end of file +} diff --git a/src/Wrapper/IterableWrapper.php b/src/Wrapper/IterableWrapper.php index f627fad..bbb7a8c 100644 --- a/src/Wrapper/IterableWrapper.php +++ b/src/Wrapper/IterableWrapper.php @@ -41,7 +41,7 @@ public static function wrap(string $function, string $paramName, mixed $iterable $prefix = ($paramName === 'return') ? "$function(): Return iterator" : "$function(): Iterator \$$paramName"; $typeCheckCallback = self::createValidationCallback($registry, $keyTypeNode, $itemTypeNode, $prefix); - if ($iterable instanceof \Traversable && ! ($iterable instanceof \Generator)) { + if (! ($iterable instanceof \Generator)) { return new IteratorProxy($iterable, $typeCheckCallback); } @@ -110,7 +110,10 @@ private static function createValidationCallback( /** * Wraps an iterable generator in an interceptor generator to evaluate type checks on each yield. * + * @param iterable $iterable * @param \Closure(mixed, mixed): void $typeCheckCallback + * + * @return \Generator */ private static function wrapGenerator(iterable $iterable, \Closure $typeCheckCallback): \Generator { diff --git a/tests/Command/CommandRunnerTest.php b/tests/Command/CommandRunnerTest.php index 3ca0fe4..bd40b0c 100644 --- a/tests/Command/CommandRunnerTest.php +++ b/tests/Command/CommandRunnerTest.php @@ -16,7 +16,8 @@ fclose($stream); expect($exitCode)->toBe(0) - ->and($output)->toContain('USAGE'); + ->and($output)->toContain('USAGE') + ; }); test('routes config:init command successfully', function () { @@ -28,7 +29,8 @@ fclose($stream); expect($exitCode)->toBe(0) - ->and($output)->toContain('Configuration'); + ->and($output)->toContain('Configuration') + ; }); test('routes cache:clear command successfully', function () { @@ -61,6 +63,7 @@ fclose($stream); expect($exitCode)->toBe(1) - ->and($output)->toContain('Error'); + ->and($output)->toContain('Error') + ; }); -}); \ No newline at end of file +}); diff --git a/tests/Fixtures/Generics/GenericBox.php b/tests/Fixtures/Generics/GenericBox.php index 6473a53..67268ac 100644 --- a/tests/Fixtures/Generics/GenericBox.php +++ b/tests/Fixtures/Generics/GenericBox.php @@ -21,4 +21,4 @@ public function set(mixed $item): void { $this->item = $item; } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Generics/GenericBoxWithMagicClone.php b/tests/Fixtures/Generics/GenericBoxWithMagicClone.php index bbcf6b4..e3905a1 100644 --- a/tests/Fixtures/Generics/GenericBoxWithMagicClone.php +++ b/tests/Fixtures/Generics/GenericBoxWithMagicClone.php @@ -28,4 +28,4 @@ public function __clone(): void { $this->item = new Dog(); } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Oop/ChildClassInheritingTraitParent.php b/tests/Fixtures/Oop/ChildClassInheritingTraitParent.php index 979238e..797825a 100644 --- a/tests/Fixtures/Oop/ChildClassInheritingTraitParent.php +++ b/tests/Fixtures/Oop/ChildClassInheritingTraitParent.php @@ -7,4 +7,4 @@ class ChildClassInheritingTraitParent extends ParentClassWithTrait { // Inherits logMessage() from ParentClassWithTrait without docblock -} \ No newline at end of file +} diff --git a/tests/Fixtures/Oop/DeepTraitWithContracts.php b/tests/Fixtures/Oop/DeepTraitWithContracts.php index 3f8fb51..4fbacac 100644 --- a/tests/Fixtures/Oop/DeepTraitWithContracts.php +++ b/tests/Fixtures/Oop/DeepTraitWithContracts.php @@ -8,10 +8,11 @@ trait DeepTraitWithContracts { /** * @param positive-int $level + * * @return non-empty-string */ public function logMessage(int $level, string $msg): string { return "log_{$level}_{$msg}"; } -} \ No newline at end of file +} diff --git a/tests/Fixtures/Oop/ParentClassWithTrait.php b/tests/Fixtures/Oop/ParentClassWithTrait.php index e1254d8..5153a67 100644 --- a/tests/Fixtures/Oop/ParentClassWithTrait.php +++ b/tests/Fixtures/Oop/ParentClassWithTrait.php @@ -7,4 +7,4 @@ class ParentClassWithTrait { use DeepTraitWithContracts; -} \ No newline at end of file +} diff --git a/tests/SomeTest.php b/tests/SomeTest.php index 644dcee..27e4116 100644 --- a/tests/SomeTest.php +++ b/tests/SomeTest.php @@ -2,10 +2,9 @@ declare(strict_types=1); -test('test', function () { - /** @var array */ - $typeArray = [1, 2, 3, '1']; - - expect($typeArray)->toBeArray(); -}); +// test('test', function () { +// /** @var array */ +// $typeArray = [1, 2, 3, '1']; +// expect($typeArray)->toBeArray(); +// }); diff --git a/tests/TypeChecking/CloneGenericInstanceTest.php b/tests/TypeChecking/CloneGenericInstanceTest.php index bc0ebe3..a50c0c9 100644 --- a/tests/TypeChecking/CloneGenericInstanceTest.php +++ b/tests/TypeChecking/CloneGenericInstanceTest.php @@ -20,7 +20,8 @@ expect($clonedBox->item)->toBeInstanceOf(Dog::class); expect(fn () => $clonedBox->set(new Car())) - ->toThrow(TypeError::class, 'template T = TypePHP\Tests\Fixtures\Domain\Dog'); + ->toThrow(TypeError::class, 'template T = TypePHP\Tests\Fixtures\Domain\Dog') + ; }); test('preserves generic template bindings when an object with __clone() is cloned', function () { @@ -32,7 +33,8 @@ expect($clonedMagicBox->item)->toBeInstanceOf(Dog::class); expect(fn () => $clonedMagicBox->set(new Car())) - ->toThrow(TypeError::class, 'template T = TypePHP\Tests\Fixtures\Domain\Dog'); + ->toThrow(TypeError::class, 'template T = TypePHP\Tests\Fixtures\Domain\Dog') + ; }); test('isolates generic template bindings and object state between original and cloned instances in WeakMap', function () { @@ -50,14 +52,17 @@ // Verify WeakMap and property state isolation expect($box2->item)->toBe($dog2) - ->and($box1->item)->toBe($dog1); // $box1's item remains unchanged! + ->and($box1->item)->toBe($dog1) // $box1's item remains unchanged! + ; // Both $box1 and $box2 independently enforce T = Dog and reject Car! expect(fn () => $box1->set(new Car())) - ->toThrow(TypeError::class, 'template T = TypePHP\Tests\Fixtures\Domain\Dog'); + ->toThrow(TypeError::class, 'template T = TypePHP\Tests\Fixtures\Domain\Dog') + ; expect(fn () => $box2->set(new Car())) - ->toThrow(TypeError::class, 'template T = TypePHP\Tests\Fixtures\Domain\Dog'); + ->toThrow(TypeError::class, 'template T = TypePHP\Tests\Fixtures\Domain\Dog') + ; }); test('enforces invariant generic type matching when assigning a cloned instance to an incompatible variable annotation', function () { @@ -70,4 +75,4 @@ $box2 = clone $box1; })->toThrow(TypeError::class, 'GenericBox'); }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/OopInheritanceTest.php b/tests/TypeChecking/OopInheritanceTest.php index 061d1c5..f9c4157 100644 --- a/tests/TypeChecking/OopInheritanceTest.php +++ b/tests/TypeChecking/OopInheritanceTest.php @@ -14,10 +14,12 @@ expect($service->process(10))->toBe('item_10'); expect(fn () => $service->process(-50)) - ->toThrow(TypeError::class, 'positive-int'); + ->toThrow(TypeError::class, 'positive-int') + ; expect(fn () => $service->process(999)) - ->toThrow(TypeError::class, 'non-empty-string'); + ->toThrow(TypeError::class, 'non-empty-string') + ; }); test('inherits interface docblock contracts when method is fulfilled via Trait', function () { @@ -26,10 +28,12 @@ expect($app->execute(100))->toBe('code_100'); expect(fn () => $app->execute(-5)) - ->toThrow(TypeError::class, 'positive-int'); + ->toThrow(TypeError::class, 'positive-int') + ; expect(fn () => $app->execute(999)) - ->toThrow(TypeError::class, 'non-empty-string'); + ->toThrow(TypeError::class, 'non-empty-string') + ; }); test('inherits instance and static property @var docblocks from Traits', function () { @@ -39,13 +43,15 @@ expect($app->traitInstanceProp)->toBe(100); expect(fn () => $app->setTraitInstanceProp(-50)) - ->toThrow(TypeError::class, 'positive-int'); + ->toThrow(TypeError::class, 'positive-int') + ; ClassUsingTraitProperties::setTraitStaticProp('v2.0'); expect(ClassUsingTraitProperties::$traitStaticProp)->toBe('v2.0'); expect(fn () => ClassUsingTraitProperties::setTraitStaticProp('')) - ->toThrow(TypeError::class, 'non-empty-string'); + ->toThrow(TypeError::class, 'non-empty-string') + ; }); test('inherits trait docblock contracts across parent-child class inheritance', function () { @@ -56,4 +62,4 @@ expect(fn () => $child->logMessage(-5, 'boot')) ->toThrow(TypeError::class, 'positive-int'); }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/PhpAttributesCoexistenceTest.php b/tests/TypeChecking/PhpAttributesCoexistenceTest.php index 277fd05..df058c3 100644 --- a/tests/TypeChecking/PhpAttributesCoexistenceTest.php +++ b/tests/TypeChecking/PhpAttributesCoexistenceTest.php @@ -5,7 +5,9 @@ #[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)] class SampleAttribute { - public function __construct(public string $name = '') {} + public function __construct(public string $name = '') + { + } } class AttributedClassFixture @@ -21,6 +23,7 @@ class AttributedClassFixture #[SampleAttribute('route_method')] /** * @param positive-int $id + * * @return non-empty-string */ public function processUser( @@ -48,14 +51,17 @@ public function executeWithDocAbove(int $code): bool expect($fixture->processUser(42))->toBe('user_42'); expect(fn () => $fixture->processUser(-50)) - ->toThrow(TypeError::class, 'positive-int'); + ->toThrow(TypeError::class, 'positive-int') + ; expect($fixture->executeWithDocAbove(100))->toBeTrue(); expect(fn () => $fixture->executeWithDocAbove(-5)) - ->toThrow(TypeError::class, 'positive-int'); + ->toThrow(TypeError::class, 'positive-int') + ; expect(fn () => $fixture->id = -10) - ->toThrow(TypeError::class, 'positive-int'); + ->toThrow(TypeError::class, 'positive-int') + ; }); -}); \ No newline at end of file +}); diff --git a/tests/Unit/TypePHPTest.php b/tests/Unit/TypePHPTest.php index f884d08..06d0f9b 100644 --- a/tests/Unit/TypePHPTest.php +++ b/tests/Unit/TypePHPTest.php @@ -16,7 +16,9 @@ */ class CustomTemplateNameBox { - /** @var ItemType */ + /** + * @var ItemType + */ public mixed $item = null; } @@ -26,7 +28,9 @@ class CustomTemplateNameBox */ class MultiTemplateDictionary { - /** @var array */ + /** + * @var array + */ public array $map = []; } @@ -40,7 +44,8 @@ class MultiTemplateDictionary expect($config)->toBeArray() ->and($config)->toHaveKey('cache') - ->and($config)->toHaveKey('inline_vars'); + ->and($config)->toHaveKey('inline_vars') + ; }); test('dynamically overrides configuration settings using setConfig', function () { @@ -73,7 +78,8 @@ class MultiTemplateDictionary expect(TypePHP::getGenericType($dogCollection))->toBe(Dog::class) ->and(TypePHP::getGenericType($catCollection))->toBe(Cat::class) - ->and(TypePHP::getGenericTypes($dogCollection))->toBe(['T' => Dog::class]); + ->and(TypePHP::getGenericTypes($dogCollection))->toBe(['T' => Dog::class]) + ; // Custom Named Single Template (@template ItemType) /** @var CustomTemplateNameBox $box */ @@ -81,7 +87,8 @@ class MultiTemplateDictionary expect(TypePHP::getGenericType($box))->toBe(Dog::class) ->and(TypePHP::getGenericType($box, 'ItemType'))->toBe(Dog::class) - ->and(TypePHP::getGenericTypes($box))->toBe(['ItemType' => Dog::class]); + ->and(TypePHP::getGenericTypes($box))->toBe(['ItemType' => Dog::class]) + ; // Multiple Templates (@template K, @template V) /** @var MultiTemplateDictionary $dict */ @@ -89,24 +96,28 @@ class MultiTemplateDictionary expect(TypePHP::getGenericType($dict, 'K'))->toBe('string') ->and(TypePHP::getGenericType($dict, 'V'))->toBe(Dog::class) - ->and(TypePHP::getGenericTypes($dict))->toBe(['K' => 'string', 'V' => Dog::class]); + ->and(TypePHP::getGenericTypes($dict))->toBe(['K' => 'string', 'V' => Dog::class]) + ; // Inherited Generic Class (@extends Repository) $dogRepo = new DogRepository(); expect(TypePHP::getGenericType($dogRepo))->toBe(Dog::class) - ->and(TypePHP::getGenericTypes($dogRepo))->toBe(['T' => Dog::class]); + ->and(TypePHP::getGenericTypes($dogRepo))->toBe(['T' => Dog::class]) + ; // Unannotated Instance before and after first-use type inference $mystery = new GenericCollection(); expect(TypePHP::getGenericType($mystery))->toBeNull() - ->and(TypePHP::getGenericTypes($mystery))->toBeEmpty(); + ->and(TypePHP::getGenericTypes($mystery))->toBeEmpty() + ; $mystery->add(new Dog()); // First method call infers T = Dog! expect(TypePHP::getGenericType($mystery))->toBe(Dog::class) - ->and(TypePHP::getGenericTypes($mystery))->toBe(['T' => Dog::class]); + ->and(TypePHP::getGenericTypes($mystery))->toBe(['T' => Dog::class]) + ; }); test('inspects declared template variances on object instances', function () { @@ -116,4 +127,4 @@ class MultiTemplateDictionary expect(TypePHP::getGenericVariance($producer))->toBe('covariant') ->and(TypePHP::getGenericVariances($producer))->toBe(['T' => 'covariant']); }); -}); \ No newline at end of file +}); From 838eaa9b62a25757b0fcff686bdee797a6ff6281 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 8 Aug 2026 19:52:02 +0800 Subject: [PATCH 11/15] Add CI workflow and funding configuration; update tests for property hooks --- .github/FUNDING.yml | 1 + .github/workflows/ci.yml | 59 +++++++++++++++++++ tests/Fixtures/IgnoreTags/IgnoredMethod.php | 20 +------ tests/Fixtures/Types/PropertyHooks.php | 16 ++++- tests/TypeChecking/DocblockIgnoreTagsTest.php | 9 +-- .../TypeChecking/GenericPropertyHooksTest.php | 5 ++ tests/TypeChecking/PropertyHooksTest.php | 13 +++- 7 files changed, 96 insertions(+), 27 deletions(-) create mode 100644 .github/FUNDING.yml create mode 100644 .github/workflows/ci.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..4a34556 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [rcalicdan] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1a8d877 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + test: + name: PHP ${{ matrix.php }} (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + php: ['8.1', '8.2', '8.3', '8.4', '8.5'] + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, mbstring, zip, libxml, json, tokenizer, fileinfo + coverage: none + + - name: Get Composer Cache Directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache Composer Dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-php-${{ matrix.php }}-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-php-${{ matrix.php }}- + + - name: Install Dependencies + run: composer update --prefer-stable --prefer-dist --no-interaction --no-progress + + - name: Check Code Style (Pint) + run: ./vendor/bin/pint --test + if: matrix.os == 'ubuntu-latest' && matrix.php == '8.3' + + - name: Run Static Analysis (PHPStan) + run: ./vendor/bin/phpstan analyse --no-progress + if: matrix.os == 'ubuntu-latest' && matrix.php == '8.3' + + - name: Run Test Suite (Pest) + run: ./vendor/bin/pest --ci \ No newline at end of file diff --git a/tests/Fixtures/IgnoreTags/IgnoredMethod.php b/tests/Fixtures/IgnoreTags/IgnoredMethod.php index 469a7ea..1651f6e 100644 --- a/tests/Fixtures/IgnoreTags/IgnoredMethod.php +++ b/tests/Fixtures/IgnoreTags/IgnoredMethod.php @@ -22,28 +22,14 @@ class IgnoredMethod */ public int $ignoredProperty = 10; - /** - * Ignored property hook -> Property hook checks skipped! - * - * @typephp-ignore - * - * @var positive-int - */ - public int $ignoredHook { - get => $this->_hookVal; - set => $this->_hookVal = $value; - } - - public int $_hookVal = 10; - public function setNormalProperty(int $val): void { - $this->normalProperty = $val; // TypePHP intercepts this! + $this->normalProperty = $val; } public function setIgnoredProperty(int $val): void { - $this->ignoredProperty = $val; // TypePHP intercepts this, but parser ignores it! + $this->ignoredProperty = $val; } /** @@ -69,4 +55,4 @@ public function ignoredMethod(int $id): int { return $id; } -} +} \ No newline at end of file diff --git a/tests/Fixtures/Types/PropertyHooks.php b/tests/Fixtures/Types/PropertyHooks.php index be8b900..7195b9b 100644 --- a/tests/Fixtures/Types/PropertyHooks.php +++ b/tests/Fixtures/Types/PropertyHooks.php @@ -44,4 +44,18 @@ class PropertyHooks } public int $_blockSetNumber = 10; -} + + /** + * Ignored property hook - Hook validation skipped + * + * @typephp-ignore + * + * @var positive-int + */ + public int $unvalidatedHook { + get => $this->_unvalidatedVal; + set => $this->_unvalidatedVal = $value; + } + + public int $_unvalidatedVal = 10; +} \ No newline at end of file diff --git a/tests/TypeChecking/DocblockIgnoreTagsTest.php b/tests/TypeChecking/DocblockIgnoreTagsTest.php index 43888be..573c9ae 100644 --- a/tests/TypeChecking/DocblockIgnoreTagsTest.php +++ b/tests/TypeChecking/DocblockIgnoreTagsTest.php @@ -46,17 +46,10 @@ function testIgnoredFunction(int $id): int expect($fixture->ignoredProperty)->toBe(-5); }); - test('skips type-checking on PHP 8.4 property hook marked with @typephp-ignore', function () { - $fixture = new IgnoredMethod(); - - $fixture->ignoredHook = -50; - expect($fixture->ignoredHook)->toBe(-50); - }); - test('skips type-checking on entire file marked with @typephp-ignore-file', function () { $fileFixture = new IgnoredFile(); $result = $fileFixture->process(-500); expect($result)->toBe(-500); }); -}); +}); \ No newline at end of file diff --git a/tests/TypeChecking/GenericPropertyHooksTest.php b/tests/TypeChecking/GenericPropertyHooksTest.php index af634c0..b21d695 100644 --- a/tests/TypeChecking/GenericPropertyHooksTest.php +++ b/tests/TypeChecking/GenericPropertyHooksTest.php @@ -2,6 +2,11 @@ declare(strict_types=1); +if (PHP_VERSION_ID < 80400) { + return; +} + + use TypePHP\Tests\Fixtures\Generics\HookedCollection; describe('Generic Template Substitution in PHP 8.4 Property Hooks', function () { diff --git a/tests/TypeChecking/PropertyHooksTest.php b/tests/TypeChecking/PropertyHooksTest.php index c0a1a6e..77f5684 100644 --- a/tests/TypeChecking/PropertyHooksTest.php +++ b/tests/TypeChecking/PropertyHooksTest.php @@ -2,6 +2,10 @@ declare(strict_types=1); +if (PHP_VERSION_ID < 80400) { + return; +} + use TypePHP\Internal\Config; use TypePHP\Tests\Fixtures\Domain\User; use TypePHP\Tests\Fixtures\Types\HookedInterfaceImplementation; @@ -65,6 +69,13 @@ ; }); + test('skips type-checking on PHP 8.4 property hook marked with @typephp-ignore', function () { + $fixture = new PropertyHooks(); + + $fixture->unvalidatedHook = -50; + expect($fixture->unvalidatedHook)->toBe(-50); + }); + test('validates asymmetric visibility properties combined with property hooks', function () { $profile = new User(); @@ -107,4 +118,4 @@ ->toThrow(TypeError::class, 'positive-int') ; }); -}); +}); \ No newline at end of file From e706fe31b2b137d983ea15c6b9ba17ded25a92aa Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 8 Aug 2026 20:15:53 +0800 Subject: [PATCH 12/15] Enhance documentation for float literals; add tests for precision and strictness --- docs/index.md | 12 +-- .../supported-types/primitives-and-scalars.md | 68 +++++++++--- internals/test_float_literals.php | 102 ++++++++++++++++++ src/Validator/ConstValidator.php | 11 +- tests/TypeChecking/FloatLiteralsTest.php | 69 ++++++++++++ 5 files changed, 242 insertions(+), 20 deletions(-) create mode 100644 internals/test_float_literals.php create mode 100644 tests/TypeChecking/FloatLiteralsTest.php diff --git a/docs/index.md b/docs/index.md index ec5d315..7a23df7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ layout: home hero: name: "TypePHP" text: "Transparent Runtime Type Enforcement" - tagline: "Enforces DocBlock types at runtime transparently without introducing any new syntax. Written in pure PHP requiring absolutely zero C-extensions or FFI." + tagline: "The first pure PHP library to enforce DocBlock types at runtime transparently without introducing any new syntax. Validates generics, array shapes, and advanced type contracts during execution." actions: - theme: brand text: "Get Started →" @@ -26,7 +26,7 @@ features: ## See It In Action -TypePHP operates entirely in user-land using native PHP stream wrappers and AST transformations. Because it is written in pure PHP and requires no C-extensions or FFI, you can drop it into any project effortlessly. It parses your standard PHPDoc annotations and enforces them the moment your code runs. +TypePHP is the first pure PHP library that operates entirely in user-land using native stream wrappers and AST transformations. Because it requires no C-extensions or FFI, you can drop it into any PHP 8.1+ project effortlessly. It parses your standard PHPDoc annotations and enforces them the moment your code runs. ### True Runtime Generics Define generic templates and TypePHP will track their state in memory per object instance: @@ -48,7 +48,7 @@ $users = new Collection(); $users->add(new User('Alice')); // Valid $users->add(new Product('SKU-100')); -// TypeError: Argument $item (template T = User) must be of type User, Product given +// Throws TypeError: Argument $item (template T = User) must be of type User, Product given ``` ### Array Shapes & Typed Arrays @@ -73,7 +73,7 @@ processBatch( options: ['status' => 'archived', 'tags' => ['php']], collaborators: [] ); -// TypeError: Argument $options['status'] must be of type ('active' | 'pending') +// Throws TypeError: Argument $options['status'] must be of type ('active' | 'pending') ``` ### Scalar Refinements & Function Boundaries @@ -86,11 +86,11 @@ Catch invalid parameters before your function executes, and invalid return value */ function generateUserToken(int $id): string { - return ""; // TypeError: Return value must be of type non-empty-string + return ""; // Throws TypeError: Return value must be of type non-empty-string } generateUserToken(-5); -// TypeError: Argument $id must be of type positive-int, negative int (-5) given +// Throws TypeError: Argument $id must be of type positive-int, negative int (-5) given ``` --- diff --git a/docs/supported-types/primitives-and-scalars.md b/docs/supported-types/primitives-and-scalars.md index 7576370..a8ce431 100644 --- a/docs/supported-types/primitives-and-scalars.md +++ b/docs/supported-types/primitives-and-scalars.md @@ -1,6 +1,6 @@ # Primitives & Scalars -TypePHP provides runtime enforcement for all native PHP primitives, extended integer ranges, string refinements, class-string subtypes, float constraints, resources, and special return control types. +TypePHP provides runtime enforcement for all native PHP primitives, literal scalar values, extended integer ranges, string refinements, class-string subtypes, float constraints, resources, and special return control types. --- @@ -32,18 +32,60 @@ function processPrimitive(int $id, string $name, bool $active): void --- +## Literal Scalar Values + +TypePHP supports enforcing exact literal scalar values in DocBlock types. The incoming value must strictly match the declared literal: + +| Literal Category | Example Syntax | Valid Value | Invalid Example | +| :--- | :--- | :--- | :--- | +| **String Literals** | `'active'`, `'admin'` | `'active'` | `'inactive'`, `'user'` | +| **Integer Literals** | `42`, `200` | `42` | `43`, `'42'` | +| **Float Literals** | `3.14`, `0.01` | `3.14`, `3` | `3.15`, `'3.14'` | +| **Boolean Literals** | `true`, `false` | `true` | `false`, `1` | + +```php +/** + * @param 'active' $status // Requires exact string 'active' + * @param 200 $code // Requires exact integer 200 + * @param true $debug // Requires exact boolean true + * @param 0.3 $threshold // Requires float literal 0.3 + */ +function setEnvironment(string $status, int $code, bool $debug, float $threshold): void +{ + // ... +} + +// Valid Call +setEnvironment('active', 200, true, 0.3); + +// Invalid Call ('inactive' is not literal 'active') +setEnvironment('inactive', 200, true, 0.3); +// Throws: TypeError: setEnvironment(): Argument $status must be literal 'active', string 'inactive' given +``` + +### Floating-Point Precision & Epsilon Comparison + +Floating-point arithmetic in computers uses IEEE 754 representation, where mathematical operations like `0.1 + 0.2` evaluate to `0.30000000000000004`. + +To prevent rounding artifacts from causing unexpected type failures, TypePHP evaluates float literals using **Epsilon Tolerance (`1e-9`)**: + +1. **IEEE 754 Arithmetic Handling:** Floating-point calculations evaluating to `0.30000000000000004` safely satisfy `@param 0.3`. +2. **Integer Coercion Support:** Integer values like `10` safely satisfy float literal types like `@param 10.0` in accordance with PHP scalar coercion rules. + +--- + ## Integer Refinements and Ranges TypePHP enforces exact value constraints and bounds on integer parameters: | Refinement Keyword | Constraint Rule | Valid Examples | Invalid Examples | | :--- | :--- | :--- | :--- | -| **`positive-int`** | Integer $> 0$ | `1`, `42`, `100` | `0`, `-5` | -| **`negative-int`** | Integer $< 0$ | `-1`, `-42` | `0`, `5` | -| **`non-positive-int`** | Integer $\le 0$ | `0`, `-1`, `-10` | `1`, `5` | -| **`non-negative-int`** | Integer $\ge 0$ | `0`, `1`, `100` | `-1`, `-5` | -| **`non-zero-int`** | Integer $\ne 0$ | `1`, `-1`, `100` | `0` | -| **`unsigned-int`** | Integer $\ge 0$ | `0`, `10`, `50` | `-10` | +| **`positive-int`** | Integer > 0 | `1`, `42`, `100` | `0`, `-5` | +| **`negative-int`** | Integer < 0 | `-1`, `-42` | `0`, `5` | +| **`non-positive-int`** | Integer <= 0 | `0`, `-1`, `-10` | `1`, `5` | +| **`non-negative-int`** | Integer >= 0 | `0`, `1`, `100` | `-1`, `-5` | +| **`non-zero-int`** | Integer != 0 | `1`, `-1`, `100` | `0` | +| **`unsigned-int`** | Integer >= 0 | `0`, `10`, `50` | `-10` | ### Integer Bounds (`int`) @@ -76,7 +118,7 @@ Validate string lengths, formatting, character casing, and truthiness at runtime | Refinement Keyword | Constraint Rule | Valid Examples | Invalid Examples | | :--- | :--- | :--- | :--- | -| **`non-empty-string`** | String length $> 0$ | `'hello'`, `'1'` | `''` (empty string) | +| **`non-empty-string`** | String length > 0 | `'hello'`, `'1'` | `''` (empty string) | | **`numeric-string`** | `is_numeric($val) === true` | `'123'`, `'45.67'`, `'-10'` | `'abc'`, `''` | | **`lowercase-string`** | `strtolower($val) === $val` | `'hello'`, `'user_100'` | `'Hello'`, `'ADMIN'` | | **`non-empty-lowercase-string`** | Non-empty & lowercase | `'hello'`, `'abc'` | `''`, `'Hello'` | @@ -154,11 +196,11 @@ TypePHP enforces signs and bounds on floating-point parameters: | Refinement Keyword | Constraint Rule | Valid Examples | Invalid Examples | | :--- | :--- | :--- | :--- | -| **`positive-float`** | Float $> 0.0$ | `12.34`, `0.01` | `0.0`, `-5.5` | -| **`negative-float`** | Float $< 0.0$ | `-12.34`, `-0.01` | `0.0`, `5.5` | -| **`non-positive-float`** | Float $\le 0.0$ | `0.0`, `-1.5` | `1.5` | -| **`non-negative-float`** | Float $\ge 0.0$ | `0.0`, `1.5` | `-1.5` | -| **`non-zero-float`** | Float $\ne 0.0$ | `1.5`, `-1.5` | `0.0` | +| **`positive-float`** | Float > 0.0 | `12.34`, `0.01` | `0.0`, `-5.5` | +| **`negative-float`** | Float < 0.0 | `-12.34`, `-0.01` | `0.0`, `5.5` | +| **`non-positive-float`** | Float <= 0.0 | `0.0`, `-1.5` | `1.5` | +| **`non-negative-float`** | Float >= 0.0 | `0.0`, `1.5` | `-1.5` | +| **`non-zero-float`** | Float != 0.0 | `1.5`, `-1.5` | `0.0` | ```php /** diff --git a/internals/test_float_literals.php b/internals/test_float_literals.php new file mode 100644 index 0000000..9ed2dd7 --- /dev/null +++ b/internals/test_float_literals.php @@ -0,0 +1,102 @@ +getMessage() . "\n"; +} + +try { + testSimpleFloatLiteral(12.35); + echo " ❌ Failed to catch invalid float literal!\n"; +} catch (TypeError $e) { + echo " ✅ CAUGHT EXPECTED ERROR: " . $e->getMessage() . "\n"; +} + +// Test 2: Floating-Point Precision (0.1 + 0.2 = 0.30000000000000004) +echo "\n2. Testing Floating-Point Arithmetic Precision (0.1 + 0.2 vs @param 0.3)...\n"; +$sum = 0.1 + 0.2; // In IEEE 754 arithmetic, this evaluates to 0.30000000000000004 +echo " Computed sum (0.1 + 0.2): " . sprintf('%.17f', $sum) . "\n"; + +try { + testPrecisionFloatLiteral($sum); + echo " ✅ Passed float precision check!\n"; +} catch (TypeError $e) { + echo " ⚠️ CAUGHT STRICT MISMATCH (Float Precision Edge Case): " . $e->getMessage() . "\n"; +} + +// Test 3: Float Zero Literal (0.0 vs -0.0) +echo "\n3. Testing Float Zero (0.0 vs -0.0)...\n"; +try { + testFloatZeroLiteral(0.0); + echo " ✅ 0.0 passed!\n"; +} catch (TypeError $e) { + echo " ❌ ERROR: " . $e->getMessage() . "\n"; +} + +try { + testFloatZeroLiteral(-0.0); + echo " ✅ -0.0 passed!\n"; +} catch (TypeError $e) { + echo " ⚠️ CAUGHT STRICT MISMATCH for -0.0: " . $e->getMessage() . "\n"; +} + +// Test 4: Int vs Float Strictness (10 vs 10.0) +echo "\n4. Testing Int 10 passed to @param 10.0 (Float Literal)...\n"; +try { + testFloatVsIntLiteral(10); // Passing int 10 to float literal 10.0 + echo " ✅ Int 10 accepted for float literal 10.0!\n"; +} catch (TypeError $e) { + echo " ⚠️ CAUGHT STRICT TYPE MISMATCH (Int 10 vs Float 10.0): " . $e->getMessage() . "\n"; +} + +echo "\n🎉 FLOAT LITERAL TEST COMPLETED!\n"; \ No newline at end of file diff --git a/src/Validator/ConstValidator.php b/src/Validator/ConstValidator.php index 136881f..05ac989 100644 --- a/src/Validator/ConstValidator.php +++ b/src/Validator/ConstValidator.php @@ -54,10 +54,19 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali $expected = (string) $constExpr; } + // Float Epsilon Comparison: Handles IEEE 754 precision artifacts and int-to-float coercion + if (\is_float($expected)) { + if ((! \is_float($value) && ! \is_int($value)) || abs((float) $value - $expected) > 1e-9) { + return ErrorFactory::createError($context . ' must be literal ' . (string) $constExpr . ', ' . TypeFormatter::formatGivenValue($value) . ' given'); + } + + return null; + } + if ($value !== $expected) { return ErrorFactory::createError($context . ' must be literal ' . (string) $constExpr . ', ' . TypeFormatter::formatGivenValue($value) . ' given'); } return null; } -} +} \ No newline at end of file diff --git a/tests/TypeChecking/FloatLiteralsTest.php b/tests/TypeChecking/FloatLiteralsTest.php new file mode 100644 index 0000000..90549f0 --- /dev/null +++ b/tests/TypeChecking/FloatLiteralsTest.php @@ -0,0 +1,69 @@ +toBe(12.34); + + expect(fn () => testSimpleFloatLiteralContract(12.35)) + ->toThrow(TypeError::class, 'must be literal 12.34') + ; + }); + + test('handles IEEE 754 floating-point arithmetic precision (0.1 + 0.2 vs 0.3)', function () { + $sum = 0.1 + 0.2; // Evaluates to 0.30000000000000004 in IEEE 754 + + expect(testPrecisionFloatLiteralContract($sum))->toBe($sum); + }); + + test('handles float zero literals (0.0 vs -0.0)', function () { + expect(testFloatZeroLiteralContract(0.0))->toBe(0.0); + expect(testFloatZeroLiteralContract(-0.0))->toBe(-0.0); + + expect(fn () => testFloatZeroLiteralContract(1.5)) + ->toThrow(TypeError::class, 'must be literal 0.0') + ; + }); + + test('accepts integer 10 for float literal 10.0 (integer coercion)', function () { + expect(testFloatVsIntLiteralContract(10))->toBe(10); + expect(testFloatVsIntLiteralContract(10.0))->toBe(10.0); + + expect(fn () => testFloatVsIntLiteralContract(10.5)) + ->toThrow(TypeError::class, 'must be literal 10.0') + ; + }); +}); \ No newline at end of file From a9d7a306db9e408ecc719b2c1f7a0e51af1305a3 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 8 Aug 2026 20:18:01 +0800 Subject: [PATCH 13/15] remove internal testing files --- internals/app.php | 67 ------- internals/demo.php | 61 ------ internals/demo1.php | 67 ------- internals/index.php | 42 ---- internals/target.php | 17 -- internals/test-clone-generics.php | 93 --------- internals/test-collection-prebind.php | 69 ------- internals/test-external-property.php | 42 ---- internals/test-generator-complex.php | 89 -------- internals/test-pre-bind.php | 139 ------------- internals/test-reified.php | 146 -------------- internals/test.php | 136 ------------- internals/test2.php | 245 ----------------------- internals/test3.php | 148 -------------- internals/test4.php | 13 -- internals/test5.php | 13 -- internals/test6.php | 18 -- internals/test7.php | 21 -- internals/test8.php | 15 -- internals/test_deep_inheritance.php | 98 --------- internals/test_docblock_inheritance.php | 146 -------------- internals/test_extends_generics.php | 147 -------------- internals/test_float_literals.php | 102 ---------- internals/test_generator_limitations.php | 94 --------- internals/test_generic_bounds.php | 120 ----------- internals/test_nested_extends.php | 146 -------------- internals/test_nested_generics.php | 90 --------- internals/test_oop_types.php | 122 ----------- internals/test_recursion_leak.php | 84 -------- internals/test_std_class_shapes.php | 23 --- internals/test_type_aliases.php | 103 ---------- internals/test_variadic_templates.php | 34 ---- internals/whitelisted_service.php | 15 -- 33 files changed, 2765 deletions(-) delete mode 100644 internals/app.php delete mode 100644 internals/demo.php delete mode 100644 internals/demo1.php delete mode 100644 internals/index.php delete mode 100644 internals/target.php delete mode 100644 internals/test-clone-generics.php delete mode 100644 internals/test-collection-prebind.php delete mode 100644 internals/test-external-property.php delete mode 100644 internals/test-generator-complex.php delete mode 100644 internals/test-pre-bind.php delete mode 100644 internals/test-reified.php delete mode 100644 internals/test.php delete mode 100644 internals/test2.php delete mode 100644 internals/test3.php delete mode 100644 internals/test4.php delete mode 100644 internals/test5.php delete mode 100644 internals/test6.php delete mode 100644 internals/test7.php delete mode 100644 internals/test8.php delete mode 100644 internals/test_deep_inheritance.php delete mode 100644 internals/test_docblock_inheritance.php delete mode 100644 internals/test_extends_generics.php delete mode 100644 internals/test_float_literals.php delete mode 100644 internals/test_generator_limitations.php delete mode 100644 internals/test_generic_bounds.php delete mode 100644 internals/test_nested_extends.php delete mode 100644 internals/test_nested_generics.php delete mode 100644 internals/test_oop_types.php delete mode 100644 internals/test_recursion_leak.php delete mode 100644 internals/test_std_class_shapes.php delete mode 100644 internals/test_type_aliases.php delete mode 100644 internals/test_variadic_templates.php delete mode 100644 internals/whitelisted_service.php diff --git a/internals/app.php b/internals/app.php deleted file mode 100644 index 65c8b51..0000000 --- a/internals/app.php +++ /dev/null @@ -1,67 +0,0 @@ - ...$producers - */ -function processProducers(Producer ...$producers) -{ - echo 'Successfully processed ' . \count($producers) . " producers!\n"; -} - -// 2. Variadic Complex Array Shapes -/** - * @param array{id: int, name: string} ...$users - */ -function processUserShapes(array ...$users) -{ - echo 'Successfully processed ' . \count($users) . " user shapes!\n"; -} - -/** - * @template-covariant T - */ -class Producer -{ - /** - * @param T $item - */ - public function __construct(public mixed $item) - { - } -} - -echo "=== Testing Complex Variadic Arguments ===\n\n"; - -// Valid calls -processProducers(new Producer(new Dog()), new Producer(new Cat())); -processUserShapes(['id' => 1, 'name' => 'Alice'], ['id' => 2, 'name' => 'Bob']); -echo "✅ Valid variadic calls passed!\n\n"; - -// Test 1: Invalid variadic generic object in 2nd argument -try { - processProducers(new Producer(new Dog()), new Producer('not_an_animal')); - echo "❌ Failed to catch bad 2nd variadic argument!\n"; -} catch (TypeError $e) { - echo '✅ CAUGHT EXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -// Test 2: Invalid variadic shape in 2nd argument (missing 'name' key) -try { - processUserShapes(['id' => 1, 'name' => 'Alice'], ['id' => 2]); - echo "❌ Failed to catch bad 2nd variadic shape!\n"; -} catch (TypeError $e) { - echo '✅ CAUGHT EXPECTED ERROR: ' . $e->getMessage() . "\n"; -} diff --git a/internals/demo.php b/internals/demo.php deleted file mode 100644 index 7cf4e7a..0000000 --- a/internals/demo.php +++ /dev/null @@ -1,61 +0,0 @@ - - */ - public array $items = [] { - set { - $this->items = $value; // hook re-validates against @var on every full assignment - } - } - - /** - * @param T $item - */ - public function add(mixed $item): static - { - $this->items = [...$this->items, $item]; // full reassignment -> triggers set hook - - return $this; - } -} - -echo "--- Test 5a: add() goes through the set hook every time ---\n"; - -/** @var HookedCollection $c */ -$c = new HookedCollection(); -$c->add(1); -$c->add(2); -echo "added 1, 2 OK\n"; - -try { - $c->add('bad'); - echo "unexpectedly succeeded\n"; -} catch (TypeError $e) { - echo "caught via add(): {$e->getMessage()}\n"; -} - -echo "\n--- Test 5b: direct full-array reassignment bypassing add() ---\n"; - -try { - $c->items = [1, 2, 'sneaky_bypass']; - echo "unexpectedly succeeded — direct assignment bypassed validation!\n"; -} catch (TypeError $e) { - echo "caught via property hook directly: {$e->getMessage()}\n"; -} - -echo "\n--- Test 5c: in-place array mutation via offset push, does the hook fire? ---\n"; - -try { - $c->items[] = 'still_sneaky'; // this is read-modify-write, NOT necessarily a full 'set' - echo 'items after offset push: ' . json_encode($c->items) . "\n"; -} catch (TypeError $e) { - echo "caught: {$e->getMessage()}\n"; -} diff --git a/internals/demo1.php b/internals/demo1.php deleted file mode 100644 index 0048783..0000000 --- a/internals/demo1.php +++ /dev/null @@ -1,67 +0,0 @@ -getMessage()}\n"; - } -} - -testIfBlockScope(true); - -echo "\n--- Test 6b: @var inside a closure, does it leak to the enclosing function? ---\n"; - -function testClosureScope(): void -{ - $fn = function () { - /** @var positive-int $y */ - $y = 10; - echo "inside closure: y = $y\n"; - }; - $fn(); - - try { - $y = -5; - echo "outside closure: y = $y (no throw means scope did NOT leak)\n"; - } catch (TypeError $e) { - echo "outside closure: threw — {$e->getMessage()}\n"; - } -} - -testClosureScope(); - -echo "\n--- Test 6c: @var re-declared in a nested if with a DIFFERENT type ---\n"; - -function testShadowedType(bool $flag): void -{ - /** @var positive-int $z */ - $z = 10; - - if ($flag) { - /** @var non-empty-string $z */ - $z = 'hello'; - echo "inside if: z = $z\n"; - } - - try { - $z = -5; - echo "outside if: z = $z (assignment succeeded, outer contract = ?)\n"; - } catch (TypeError $e) { - echo "outside if: threw — {$e->getMessage()}\n"; - } -} - -testShadowedType(true); diff --git a/internals/index.php b/internals/index.php deleted file mode 100644 index 71e44fc..0000000 --- a/internals/index.php +++ /dev/null @@ -1,42 +0,0 @@ - $producer - */ -function handleProducer(Producer $producer) -{ - return $producer->item; -} - -// 1. Valid: Producer satisfies Producer because Dog extends Animal! -$dogProducer = new Producer(new Dog()); -handleProducer($dogProducer); - -// 2. Fails: Producer does NOT satisfy Producer -$stringProducer = new Producer('not an animal'); -handleProducer($stringProducer); diff --git a/internals/target.php b/internals/target.php deleted file mode 100644 index b28358b..0000000 --- a/internals/target.php +++ /dev/null @@ -1,17 +0,0 @@ - - */ -function test1(): array -{ - return []; // Empty array! -} - -test1(); diff --git a/internals/test-clone-generics.php b/internals/test-clone-generics.php deleted file mode 100644 index 13fa4f0..0000000 --- a/internals/test-clone-generics.php +++ /dev/null @@ -1,93 +0,0 @@ -item = $item; - } -} - -/** - * 2. Generic Class with Explicit __clone() Magic Method - * - * @template T - */ -class GenericBoxWithMagicClone -{ - /** - * @var T - */ - public mixed $item = null; - - /** - * @param T $item - */ - public function set(mixed $item): void - { - $this->item = $item; - } - - public function __clone(): void - { - // Assign a valid Dog instance inside __clone() so it satisfies @var T (where T = Dog) - $this->item = new Dog(); - } -} - -echo "=== Testing Clone Keyword & Generic Prebinding Preservation ===\n\n"; - -// TEST 1: Standard Class (No __clone) -echo "1. Standard Class (No __clone()):\n"; -/** @var GenericBox $dogBox */ -$dogBox = new GenericBox(); - -$clonedBox = clone $dogBox; - -try { - $clonedBox->set(new Car()); - echo " ❌ FAIL: Standard cloned box accepted Car! T = Dog was lost!\n"; -} catch (TypeError $e) { - echo " ✅ SUCCESS: Caught expected TypeError!\n"; - echo ' Message: ' . $e->getMessage() . "\n\n"; -} - -// TEST 2: Class with Magic __clone() -echo "2. Class with Explicit Magic __clone():\n"; -/** @var GenericBoxWithMagicClone $magicBox */ -$magicBox = new GenericBoxWithMagicClone(); - -$clonedMagicBox = clone $magicBox; - -try { - $clonedMagicBox->set(new Car()); - echo " ❌ FAIL: Cloned box with __clone() accepted Car! T = Dog was lost!\n"; -} catch (TypeError $e) { - echo " ✅ SUCCESS: Caught expected TypeError!\n"; - echo ' Message: ' . $e->getMessage() . "\n"; -} diff --git a/internals/test-collection-prebind.php b/internals/test-collection-prebind.php deleted file mode 100644 index af3c0a5..0000000 --- a/internals/test-collection-prebind.php +++ /dev/null @@ -1,69 +0,0 @@ -items[] = $item; - - return $this; - } - - /** - * @return T[] - */ - public function toArray(): array - { - return $this->items; - } - - public function count(): int - { - return \count($this->items); - } -} - -// (Collection class same as before — omitted here for brevity, reuse from previous script) - -echo "=== TEST J: Does @var actually prebind, or is it just first-use inference? ===\n\n"; - -echo "-- Sub-test: @var Collection, but FIRST add() is a Product (no valid User has ever been added) --\n"; - -/** @var Collection $users */ -$users = new Collection(); - -try { - $users->add(new Product('SKU-999')); // first call ever on this instance — should fail if @var truly prebinds - echo " ⚠️ users->add(Product) PASSED as the first call — this means @var is NOT enforced;\n"; - echo " T is only inferred from first successful add(), the docblock annotation is not read at all.\n"; -} catch (TypeError $e) { - echo " ✅ users->add(Product) FAILED as the first call — @var Collection genuinely prebinds T\n"; - echo ' before any item exists: ' . $e->getMessage() . "\n"; -} diff --git a/internals/test-external-property.php b/internals/test-external-property.php deleted file mode 100644 index e4f4bbe..0000000 --- a/internals/test-external-property.php +++ /dev/null @@ -1,42 +0,0 @@ -age = 25; -echo " ✅ Success! Age is now: {$obj->age}\n\n"; - -// 2. Ignored Property Assignment -echo "2. Assigning invalid value to @typephp-ignore property...\n"; -$obj->ignoredAge = -50; -echo " ✅ Success! Ignored property allowed -50! Value: {$obj->ignoredAge}\n\n"; - -// 3. Invalid Assignment (Should Throw) -echo "3. Assigning invalid value (-5) to normal property...\n"; - -try { - $obj->age = -5; - echo " ❌ FAIL! The assignment succeeded but it should have thrown a TypeError!\n"; -} catch (TypeError $e) { - echo ' ✅ SUCCESS! Caught expected TypeError: ' . $e->getMessage() . "\n"; -} diff --git a/internals/test-generator-complex.php b/internals/test-generator-complex.php deleted file mode 100644 index e19dc4f..0000000 --- a/internals/test-generator-complex.php +++ /dev/null @@ -1,89 +0,0 @@ -send() - * - * @return Generator - */ -function testShapeGenerator(): Generator -{ - $input = yield 1 => ['id' => 10, 'name' => 'Alice']; - yield 2 => ['id' => 20, 'name' => "action_{$input['action']}"]; -} - -/** - * Generator yielding Generic Objects - * - * @return Generator> - */ -function testGenericGenerator(): Generator -{ - yield 1 => new Producer(new Dog()); - yield 2 => new Producer(new Car()); // Invalid: Car is not a Dog! -} - -echo "=== Testing Complex Generator Type Enforcement ===\n\n"; - -// 1. Yielding Array Shapes -echo "1. Testing Generator Yielding Array Shapes:\n"; -$gen1 = testShapeGenerator(); -$firstItem = $gen1->current(); -echo ' ✅ Success: Yielded valid shape: ' . json_encode($firstItem) . "\n\n"; - -// 2. Sending Valid Shape into Generator (TSend) -echo "2. Testing \$gen->send() with Valid TSend Shape ('action' => 'approve'):\n"; -$secondItem = $gen1->send(['action' => 'approve']); -echo ' ✅ Success: Yielded second shape: ' . json_encode($secondItem) . "\n\n"; - -// 3. Sending Invalid Shape into Generator (TSend) -echo "3. Testing \$gen->send() with Invalid TSend Shape ('action' => 'delete'):\n"; -$gen2 = testShapeGenerator(); -$gen2->current(); - -try { - $gen2->send(['action' => 'delete']); - echo " ❌ FAIL: Generator accepted invalid TSend action 'delete'!\n"; -} catch (TypeError $e) { - echo " ✅ SUCCESS: Caught expected TypeError on TSend!\n"; - echo ' Message: ' . $e->getMessage() . "\n\n"; -} - -// 4. Yielding Invalid Generic Object -echo "4. Testing Generator Yielding Invalid Generic Object (Producer):\n"; -$gen3 = testGenericGenerator(); - -try { - foreach ($gen3 as $key => $producer) { - echo " Yielded item #{$key}: " . \get_class($producer->item) . "\n"; - } - echo " ❌ FAIL: Generator yielded Producer without throwing TypeError!\n"; -} catch (TypeError $e) { - echo " ✅ SUCCESS: Caught expected TypeError on yield!\n"; - echo ' Message: ' . $e->getMessage() . "\n"; -} diff --git a/internals/test-pre-bind.php b/internals/test-pre-bind.php deleted file mode 100644 index 152c338..0000000 --- a/internals/test-pre-bind.php +++ /dev/null @@ -1,139 +0,0 @@ -items[] = $item; - - return $this; - } - - /** - * @return T[] - */ - public function toArray(): array - { - return $this->items; - } - - public function count(): int - { - return \count($this->items); - } -} - -echo "=== TEST I: Prebinding via @var docblock — /** @var Collection */ ===\n\n"; - -echo "-- Sub-test 1: prebound Collection, add(User) should pass --\n"; -/** @var Collection $users */ -$users = new Collection(); - -try { - $users->add(new User('Alice')); - echo " ✅ users->add(User) passed — T prebound to User via docblock\n"; -} catch (TypeError $e) { - echo ' ❌ UNEXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -echo "\n-- Sub-test 2: same prebound Collection, add(Product) should fail --\n"; - -try { - $users->add(new Product('SKU-123')); - echo " ❌ Failed to catch: Collection accepted a Product\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -echo "\n-- Sub-test 3: separate Collection instance, independent of Collection --\n"; -/** @var Collection $products */ -$products = new Collection(); - -try { - $products->add(new Product('SKU-456')); - echo " ✅ products->add(Product) passed\n"; -} catch (TypeError $e) { - echo ' ❌ UNEXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -try { - $products->add(new User('Bob')); - echo " ❌ Failed to catch: Collection accepted a User\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -echo "\n-- Sub-test 4: does adding a second valid User to the SAME collection still respect T? --\n"; - -try { - $users->add(new User('Carol')); - echo " ✅ users->add(User) #2 passed — T=User still enforced consistently on this instance\n"; - echo ' ℹ️ users->count() = ' . $users->count() . "\n"; -} catch (TypeError $e) { - echo ' ❌ UNEXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -echo "\n-- Sub-test 5: toArray() return — are the contents actually User instances? --\n"; -$all = $users->toArray(); -$allAreUsers = array_reduce($all, fn ($carry, $item) => $carry && $item instanceof User, true); -echo ' ℹ️ toArray() returned ' . \count($all) . ' items, all instanceof User: ' . ($allAreUsers ? 'yes' : 'no') . "\n"; - -echo "\n-- Sub-test 6: no @var annotation at all — does T fall back to unbound/inferred-from-first-add? --\n"; -$mystery = new Collection(); // no docblock this time - -try { - $mystery->add(new Dog()); - echo " ✅ mystery->add(Dog) passed (no prebinding, first add establishes T=Dog?)\n"; -} catch (TypeError $e) { - echo ' ℹ️ mystery->add(Dog) failed even with no @var — ' . $e->getMessage() . "\n"; -} - -try { - $mystery->add(new Cat()); - echo " ℹ️ mystery->add(Cat) passed after Dog — T not locked without explicit @var prebinding\n"; -} catch (TypeError $e) { - echo " ℹ️ mystery->add(Cat) failed after Dog — T locked to Dog from first add, even without @var\n"; - echo ' ' . $e->getMessage() . "\n"; -} - -echo "\n🎉 DONE — key things to look at:\n"; -echo " 1. Does @var Collection actually restrict add() before any item exists? (sub-tests 1-2)\n"; -echo " 2. Are two separate prebound instances independent? (sub-test 3)\n"; -echo " 3. Without @var, does behavior degrade gracefully (either: no enforcement, or: infer T from first add)? (sub-test 6)\n"; diff --git a/internals/test-reified.php b/internals/test-reified.php deleted file mode 100644 index 7871240..0000000 --- a/internals/test-reified.php +++ /dev/null @@ -1,146 +0,0 @@ - - */ - public array $items = []; - - /** - * @param T $item - */ - public function add(mixed $item): void - { - $this->items[] = $item; - } -} - -/** - * 2. Custom Named Single Template (@template ItemType) - * - * @template ItemType - */ -class Box -{ - /** - * @var ItemType - */ - public mixed $item = null; -} - -/** - * 3. Multiple Templates (@template K, @template V) - * - * @template K - * @template V - */ -class Dictionary -{ - /** - * @var array - */ - public array $map = []; -} - -/** - * 4. Inherited Generic Class (@extends) - * - * @template T - */ -abstract class BaseRepository -{ - /** - * @param T $entity - */ - public function save(mixed $entity): void - { - } -} - -/** - * @extends BaseRepository - */ -class UserRepository extends BaseRepository -{ -} - -echo "=== Testing Reified Generics API (TypePHP::getGenericType) ===\n\n"; - -// Scenario 1: Standard Single Template (Collection vs Collection) -echo "1. Standard Single Template (@template T):\n"; -/** @var Collection $users */ -$users = new Collection(); - -/** @var Collection $products */ -$products = new Collection(); - -echo ' User Collection T: ' . TypePHP::getGenericType($users) . "\n"; -echo ' Product Collection T: ' . TypePHP::getGenericType($products) . "\n"; -echo ' All Types Array: ' . json_encode(TypePHP::getGenericTypes($users)) . "\n\n"; - -// Scenario 2: Custom Template Parameter Name (@template ItemType) -echo "2. Custom Template Parameter Name (@template ItemType):\n"; -/** @var Box $orderBox */ -$orderBox = new Box(); - -echo ' Smart Fallback Type: ' . TypePHP::getGenericType($orderBox) . "\n"; -echo " Explicit 'ItemType': " . TypePHP::getGenericType($orderBox, 'ItemType') . "\n"; -echo ' All Types Array: ' . json_encode(TypePHP::getGenericTypes($orderBox)) . "\n\n"; - -// Scenario 3: Multiple Template Parameters (@template K, @template V) -echo "3. Multiple Template Parameters (@template K, @template V):\n"; -/** @var Dictionary $catalog */ -$catalog = new Dictionary(); - -echo ' Key Template K: ' . TypePHP::getGenericType($catalog, 'K') . "\n"; -echo ' Value Template V: ' . TypePHP::getGenericType($catalog, 'V') . "\n"; -echo ' All Types Array: ' . json_encode(TypePHP::getGenericTypes($catalog)) . "\n\n"; - -// Scenario 4: Inherited Generics via @extends -echo "4. Inherited Generic Class (@extends BaseRepository):\n"; -$userRepo = new UserRepository(); - -echo ' Inherited Repo T: ' . TypePHP::getGenericType($userRepo) . "\n"; -echo ' All Types Array: ' . json_encode(TypePHP::getGenericTypes($userRepo)) . "\n\n"; - -// Scenario 5: Unannotated Generic Instance (Before First Use) -echo "5. Unannotated Generic Instance (Before First Use):\n"; -$mystery = new Collection(); - -echo ' Unbound Type: ' . (TypePHP::getGenericType($mystery) ?? 'null') . "\n"; -echo ' All Types Array: ' . json_encode(TypePHP::getGenericTypes($mystery)) . "\n\n"; - -// Scenario 6: First-Use Type Inference -echo "6. First-Use Type Inference (After First Method Call):\n"; -$mystery->add(new User('Bob')); // First method call infers T = User! -echo ' Inferred Type T: ' . TypePHP::getGenericType($mystery) . "\n"; -echo ' All Types Array: ' . json_encode(TypePHP::getGenericTypes($mystery)) . "\n"; diff --git a/internals/test.php b/internals/test.php deleted file mode 100644 index d48d53a..0000000 --- a/internals/test.php +++ /dev/null @@ -1,136 +0,0 @@ -item; - } - - /** - * @param T $item - */ - public function set(mixed $item): void - { - $this->item = $item; - } -} - -// ------------------------------------------------------------- -// TEST C: Array-shaped T — each element must match the *bound* T, -// not just the declared bound (Animal) -// ------------------------------------------------------------- -/** - * @template T of Animal - * - * @param T $seed - * @param T[] $items - */ -function checkAll(mixed $seed, array $items): void -{ - // no-op, just testing the param validation -} - -echo "=== TEST A: T consistency across parameters ===\n"; - -// Valid: both Dog, T = Dog throughout -try { - pickFirst(new Dog(), new Dog()); - echo " ✅ pickFirst(Dog, Dog) passed (T fixed to Dog)\n"; -} catch (TypeError $e) { - echo ' ❌ UNEXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -// Invalid: Dog then Cat — both satisfy bound Animal individually, -// but T should be fixed to Dog after the first arg -try { - pickFirst(new Dog(), new Cat()); - echo " ❌ Failed to catch T mismatch: pickFirst(Dog, Cat) should fail (T fixed to Dog, Cat given)\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -echo "\n=== TEST B: T persistence across methods on same instance ===\n"; - -$box = new Box(new Dog()); // T = Dog for this instance -echo " ✅ Box(Dog) constructed, T bound to Dog\n"; - -// Invalid: same instance, T is Dog, calling set() with Cat should fail -try { - $box->set(new Cat()); - echo " ❌ Failed to catch: Box::set(Cat) should fail (T is Dog, not Cat)\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -// get() should still report/return Dog-typed value at this point -$got = $box->get(); -echo ' ℹ️ Box::get() returned instance of: ' . \get_class($got) . "\n"; - -echo "\n=== TEST C: Array-shaped T bound consistency ===\n"; - -// Valid: seed is Dog, all items are Dog -try { - checkAll(new Dog(), [new Dog(), new Dog()]); - echo " ✅ checkAll(Dog, [Dog, Dog]) passed\n"; -} catch (TypeError $e) { - echo ' ❌ UNEXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -// Invalid: seed is Dog, but items contain a Cat — each item individually -// satisfies bound Animal, but not the fixed T = Dog -try { - checkAll(new Dog(), [new Dog(), new Cat()]); - echo " ❌ Failed to catch: checkAll(Dog, [Dog, Cat]) should fail (T fixed to Dog, Cat in array)\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -echo "\n🎉 DONE — results above show whether T is bound per-call-site/per-instance, or just checked against the raw bound (Animal) each time.\n"; diff --git a/internals/test2.php b/internals/test2.php deleted file mode 100644 index c9ae744..0000000 --- a/internals/test2.php +++ /dev/null @@ -1,245 +0,0 @@ -> -// ------------------------------------------------------------- -/** - * @template T of Animal - */ -class Box -{ - /** - * @param T $item - */ - public function __construct(public mixed $item) - { - } - - /** - * @return T - */ - public function get(): mixed - { - return $this->item; - } - - /** - * @param T $item - */ - public function set(mixed $item): void - { - $this->item = $item; - } -} - -// ------------------------------------------------------------- -// TEST G: Performance under load -// ------------------------------------------------------------- - -// ------------------------------------------------------------- -// TEST H: Unbinding / re-binding across separate calls -// ------------------------------------------------------------- - -echo "=== TEST D: Variance (bound=Dog, arg=Puppy subclass) ===\n"; - -try { - $result = pickFirst(new Dog(), new Puppy()); - echo " ⚠️ pickFirst(Dog, Puppy) PASSED — library accepts subtypes of bound T (covariant-ish behavior)\n"; -} catch (TypeError $e) { - echo " ⚠️ pickFirst(Dog, Puppy) FAILED — library demands exact type match for bound T\n"; - echo ' ' . $e->getMessage() . "\n"; -} -echo " (Neither outcome is wrong — just confirms which design your library implements)\n"; - -echo "\n=== TEST E: Multiple independent template params (T, U) ===\n"; - -// Valid: T=Dog throughout, U=Car throughout -try { - pairUp(new Dog(), new Car(), new Dog(), new Car()); - echo " ✅ pairUp(Dog, Car, Dog, Car) passed — T and U each independently consistent\n"; -} catch (TypeError $e) { - echo ' ❌ UNEXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -// Invalid: T flips from Dog to Cat (T inconsistency), U stays Car -try { - pairUp(new Dog(), new Car(), new Cat(), new Car()); - echo " ❌ Failed to catch: T changed from Dog to Cat but U was fine\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR (T leak): ' . $e->getMessage() . "\n"; -} - -// Invalid: T stays Dog, but U flips from Car to SportsCar (subtype) — tests U independence + variance on U -try { - pairUp(new Dog(), new Car(), new Dog(), new SportsCar()); - echo " ⚠️ pairUp(..., Car, ..., SportsCar) passed — U accepted a Car subtype\n"; -} catch (TypeError $e) { - echo " ⚠️ pairUp(..., Car, ..., SportsCar) failed — U demanded exact match\n"; - echo ' ' . $e->getMessage() . "\n"; -} - -// Invalid: T and U swapped types entirely — T given a Car, U given an Animal -try { - pairUp(new Car(), new Dog(), new Car(), new Dog()); - echo " ❌ Failed to catch: T/U bounds swapped (T got Car, should need Animal bound; U got Dog, should need Car bound)\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR (bound violation, not just consistency): ' . $e->getMessage() . "\n"; -} - -echo "\n=== TEST F: Nested generics — Box> ===\n"; - -try { - $innerBox = new Box(new Dog()); - $outerBox = new Box($innerBox); // T for outer Box should resolve to "Box", not just "Animal" - echo " ✅ Box(Box(Dog)) constructed without error\n"; - - $unwrapped = $outerBox->get(); - if ($unwrapped instanceof Box) { - echo " ℹ️ outerBox->get() returned a Box instance (nested structure preserved)\n"; - $innerVal = $unwrapped->get(); - echo ' ℹ️ innerBox->get() returned instance of: ' . \get_class($innerVal) . "\n"; - } else { - echo " ⚠️ outerBox->get() did NOT return a Box — nested T may not be tracked, just treated as 'mixed'\n"; - } -} catch (TypeError $e) { - echo ' ⚠️ Box(Box(Dog)) threw unexpectedly: ' . $e->getMessage() . "\n"; - echo " (This may mean Box rejects a Box itself, since Box is not an Animal)\n"; -} - -echo "\n=== TEST G: Performance under load (100,000 calls) ===\n"; - -$iterations = 100_000; -$start = microtime(true); -for ($i = 0; $i < $iterations; $i++) { - pickFirst(new Dog(), new Dog()); -} -$elapsed = microtime(true) - $start; -$perCall = ($elapsed / $iterations) * 1_000_000; // microseconds - -echo " {$iterations} calls in " . number_format($elapsed, 4) . "s\n"; -echo ' ~' . number_format($perCall, 2) . " microseconds per call\n"; - -// Compare against a plain non-generic baseline for context -function plainPick(Animal $a, Animal $b): Animal -{ - return $a; -} - -$start = microtime(true); -for ($i = 0; $i < $iterations; $i++) { - plainPick(new Dog(), new Dog()); -} -$elapsedPlain = microtime(true) - $start; -$perCallPlain = ($elapsedPlain / $iterations) * 1_000_000; - -echo ' Baseline (native type-hints, no template): ~' . number_format($perCallPlain, 2) . " microseconds per call\n"; -echo ' Overhead multiplier: ' . number_format($perCall / max($perCallPlain, 0.0001), 1) . "x\n"; - -echo "\n=== TEST H: T scoping across separate, unrelated calls ===\n"; - -// Call 1: bind T=Cat in one call -try { - pickFirst(new Cat(), new Cat()); - echo " ✅ Call 1: pickFirst(Cat, Cat) passed, T=Cat for this call\n"; -} catch (TypeError $e) { - echo ' ❌ UNEXPECTED ERROR on Call 1: ' . $e->getMessage() . "\n"; -} - -// Call 2: immediately after, bind T=Dog in a totally separate call -// If T leaked from Call 1 (cached as Cat somewhere it shouldn't be), -// this would incorrectly fail or behave oddly. -try { - pickFirst(new Dog(), new Dog()); - echo " ✅ Call 2: pickFirst(Dog, Dog) passed, T=Dog — no leakage from Call 1's T=Cat\n"; -} catch (TypeError $e) { - echo ' ❌ LEAKAGE BUG: Call 2 failed, suggesting T from Call 1 (Cat) leaked into Call 2: ' . $e->getMessage() . "\n"; -} - -// Call 3: interleave Box and Box instances and confirm each tracks -// its own T independently (no shared/static state between instances) -$dogBox = new Box(new Dog()); -$catBox = new Box(new Cat()); - -try { - $dogBox->set(new Dog()); // should pass - echo " ✅ dogBox->set(Dog) passed\n"; -} catch (TypeError $e) { - echo ' ❌ UNEXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -try { - $catBox->set(new Cat()); // should pass - echo " ✅ catBox->set(Cat) passed\n"; -} catch (TypeError $e) { - echo ' ❌ UNEXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -try { - $dogBox->set(new Cat()); // should fail — dogBox's T is Dog - echo " ❌ LEAKAGE BUG: dogBox accepted a Cat (its T should be locked to Dog)\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR: dogBox correctly rejected Cat: ' . $e->getMessage() . "\n"; -} - -try { - $catBox->set(new Dog()); // should fail — catBox's T is Cat - echo " ❌ LEAKAGE BUG: catBox accepted a Dog (its T should be locked to Cat)\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR: catBox correctly rejected Dog: ' . $e->getMessage() . "\n"; -} - -echo "\n🎉 DONE\n"; diff --git a/internals/test3.php b/internals/test3.php deleted file mode 100644 index 1e18ae5..0000000 --- a/internals/test3.php +++ /dev/null @@ -1,148 +0,0 @@ -item; - } - - /** - * @param T $item - */ - public function set(mixed $item): void - { - $this->item = $item; - } -} - -// ------------------------------------------------------------- -// A container that explicitly expects to hold multiple T's, -// to test array-of-T alongside nested Box -// ------------------------------------------------------------- -/** - * @template T - */ -class Pack -{ - /** - * @param T[] $items - */ - public function __construct(public array $items) - { - } - - /** - * @param T $item - */ - public function add(mixed $item): void - { - $this->items[] = $item; - } -} - -echo "=== TEST F-2: Nested generics — Box> (unbounded T) ===\n"; - -try { - $innerBox = new Box(new Dog()); // Box - $outerBox = new Box($innerBox); // Box> — T bound to "Box" (or ideally "Box") - echo " ✅ Box(Box(Dog)) constructed without error\n"; - - $unwrapped = $outerBox->get(); - if ($unwrapped instanceof Box) { - echo " ✅ outerBox->get() returned a Box instance — nested structure preserved\n"; - $innerVal = $unwrapped->get(); - echo ' ℹ️ innerBox->get() returned instance of: ' . \get_class($innerVal) . "\n"; - } else { - echo " ⚠️ outerBox->get() did NOT return a Box\n"; - } -} catch (TypeError $e) { - echo ' ❌ UNEXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -// Now test whether T, once bound to "Box" (from the Dog-holding box), -// enforces consistency the same way scalar T's did earlier — -// i.e. does outerBox reject being set to a Box holding something else, -// or does "T = Box" mean ANY Box qualifies, regardless of what's inside it? -echo "\n -- Sub-test: does outer T track WHAT KIND of Box, or just 'a Box'? --\n"; - -try { - $anotherInnerBox = new Box(new Cat()); // Box, different inner type - $outerBox->set($anotherInnerBox); - echo " ℹ️ outerBox->set(Box) passed — T is tracked only as 'Box', not 'Box' specifically\n"; - echo " (This tells us whether nesting is type-erased at one level or fully recursive)\n"; -} catch (TypeError $e) { - echo " ℹ️ outerBox->set(Box) FAILED — T is tracked recursively as 'Box', rejecting Box\n"; - echo ' ' . $e->getMessage() . "\n"; -} - -// Sanity check: outer T should still reject a non-Box entirely (e.g. a plain Dog), -// since T was bound to "Box" (or Box) on first use, not "Animal" -try { - $outerBox->set(new Dog()); - echo " ❌ POSSIBLE ISSUE: outerBox accepted a plain Dog, even though T was bound to Box on construction\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR: outerBox correctly rejected a plain Dog (T is bound to Box, not Animal): ' . $e->getMessage() . "\n"; -} - -echo "\n=== TEST F-3: T[] resolving through a nested container (Pack>) ===\n"; - -try { - $box1 = new Box(new Dog()); - $box2 = new Box(new Dog()); - $pack = new Pack([$box1, $box2]); // Pack>, T[] should mean "array of Box" - echo " ✅ Pack([Box(Dog), Box(Dog)]) constructed — T[] resolved to array of Box\n"; -} catch (TypeError $e) { - echo ' ❌ UNEXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -// Now try adding a mismatched element — a Box instead of Box, -// or a raw Dog instead of a Box at all -try { - $pack->add(new Dog()); // raw Dog, not wrapped in a Box — should fail if T = Box - echo " ❌ POSSIBLE ISSUE: Pack accepted a raw Dog even though T should be 'Box' based on constructor items\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR: Pack correctly rejected a raw Dog (T is Box, not Animal): ' . $e->getMessage() . "\n"; -} - -try { - $box3 = new Box(new Cat()); - $pack->add($box3); // Box — does Pack's T care about what's inside the Box? - echo " ℹ️ Pack->add(Box) passed — T tracked only as 'Box' at this level, inner type not enforced across Pack\n"; -} catch (TypeError $e) { - echo " ℹ️ Pack->add(Box) FAILED — T tracked recursively, rejecting Box when Pack established Box\n"; - echo ' ' . $e->getMessage() . "\n"; -} - -echo "\n🎉 DONE — key question: does nesting stop at one level (T='Box') or resolve recursively (T='Box')?\n"; diff --git a/internals/test4.php b/internals/test4.php deleted file mode 100644 index 0440037..0000000 --- a/internals/test4.php +++ /dev/null @@ -1,13 +0,0 @@ -strings = $strings; - } -} - -new Strings(['a', 'b', 'c', 1]); diff --git a/internals/test7.php b/internals/test7.php deleted file mode 100644 index b634f44..0000000 --- a/internals/test7.php +++ /dev/null @@ -1,21 +0,0 @@ - ['hello', 1]; - } - - public function run() - { - $numbers = $this->numbers; - var_dump($numbers); - } -} - -new Test()->run(); diff --git a/internals/test8.php b/internals/test8.php deleted file mode 100644 index 7eacb7f..0000000 --- a/internals/test8.php +++ /dev/null @@ -1,15 +0,0 @@ - LevelB -> LevelC) -// ------------------------------------------------------------- -/** - * @phpstan-type DeepShape array{id: positive-int, score: int<1, 100>} - */ -class LevelA -{ -} - -/** - * @phpstan-import-type DeepShape from LevelA - */ -class LevelB -{ -} - -/** - * @phpstan-import-type DeepShape from LevelB as LocalDeepShape - */ -class LevelC -{ - /** - * @param LocalDeepShape $payload - */ - public function process(array $payload): bool - { - return true; - } -} - -// ------------------------------------------------------------- -// 2. Deep Interface Method Inheritance (RootInterface -> MidInterface -> FinalExecutor) -// ------------------------------------------------------------- -interface RootInterface -{ - /** - * @param positive-int $code - */ - public function execute(int $code): bool; -} - -interface MidInterface extends RootInterface -{ -} - -class FinalExecutor implements MidInterface -{ - // No docblock here! Should inherit @param positive-int $code from RootInterface - public function execute(int $code): bool - { - return true; - } -} - -echo "=== Testing Deep Inheritance Features ===\n\n"; - -// 1. Chained Imported Type Alias -echo "1. Testing Chained Imported Type Alias (LevelA -> LevelB -> LevelC)...\n"; -$c = new LevelC(); - -try { - $c->process(['id' => 10, 'score' => 95]); - echo " ✅ Valid chained DeepShape passed!\n"; -} catch (TypeError $e) { - echo ' ❌ UNEXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -try { - $c->process(['id' => -1, 'score' => 95]); - echo " ❌ Failed to catch invalid chained DeepShape!\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -// 2. Deep Interface Inheritance -echo "\n2. Testing Deep Interface Inheritance (RootInterface -> MidInterface -> FinalExecutor)...\n"; -$executor = new FinalExecutor(); - -try { - $executor->execute(100); - echo " ✅ Valid execute(100) passed!\n"; -} catch (TypeError $e) { - echo ' ❌ UNEXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -try { - $executor->execute(-50); - echo " ❌ Failed to catch invalid code on deep interface inheritance!\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -echo "\n🎉 DEEP INHERITANCE TEST COMPLETED!\n"; diff --git a/internals/test_docblock_inheritance.php b/internals/test_docblock_inheritance.php deleted file mode 100644 index e0fe564..0000000 --- a/internals/test_docblock_inheritance.php +++ /dev/null @@ -1,146 +0,0 @@ - $id, 'name' => 'Alice']; - } -} - -class UserRepository extends BaseRepository -{ - // No docblock here! Should inherit @param positive-int $id and @return array{id: positive-int, name: string} - public function find(int $id): array - { - if ($id === 999) { - // Returns negative id (-5), violating inherited return shape array{id: positive-int} - return ['id' => -5, 'name' => 'Invalid']; - } - - return parent::find($id); - } -} - -// ------------------------------------------------------------- -// 2. Interface Method Inheritance -// ------------------------------------------------------------- -interface PaymentGatewayInterface -{ - /** - * @param non-empty-string $currency - * @param int<1, 1000000> $amount - */ - public function pay(string $currency, int $amount): bool; -} - -class StripeGateway implements PaymentGatewayInterface -{ - // No docblock here! Should inherit @param non-empty-string and @param int<1, 1000000> - public function pay(string $currency, int $amount): bool - { - return true; - } -} - -// ------------------------------------------------------------- -// 3. Trait Method Inheritance -// ------------------------------------------------------------- -trait LoggerTrait -{ - /** - * @param 'info'|'warning'|'error' $level - * @param non-empty-string $message - */ - public function log(string $level, string $message): void - { - } -} - -class ApplicationService -{ - use LoggerTrait; - - // Overrides method with no docblock! Should inherit @param 'info'|'warning'|'error' - public function log(string $level, string $message): void - { - } -} - -echo "=== Testing DocBlock Method Contract Inheritance (LSP) ===\n\n"; - -// 1. Parent Class Method Inheritance -echo "1. Testing Parent Class Method Inheritance...\n"; -$userRepo = new UserRepository(); - -// Valid call -$userRepo->find(10); -echo " ✅ Valid find(10) passed!\n"; - -// Invalid param: -5 is not positive-int -try { - $userRepo->find(-5); - echo " ❌ Failed to catch invalid parameter on inherited method!\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR (Param): ' . $e->getMessage() . "\n"; -} - -// Invalid return: find(999) returns ['id' => -5] -try { - $userRepo->find(999); - echo " ❌ Failed to catch invalid return on inherited method!\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR (Return): ' . $e->getMessage() . "\n"; -} - -// 2. Interface Method Inheritance -echo "\n2. Testing Interface Method Inheritance...\n"; -$stripe = new StripeGateway(); - -// Valid call -$stripe->pay('USD', 500); -echo " ✅ Valid pay('USD', 500) passed!\n"; - -// Invalid param: empty currency string '' -try { - $stripe->pay('', 500); - echo " ❌ Failed to catch empty currency on inherited interface method!\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -// Invalid param: amount 0 out of int<1, 1000000> range -try { - $stripe->pay('USD', 0); - echo " ❌ Failed to catch amount=0 on inherited interface method!\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -// 3. Trait Method Inheritance -echo "\n3. Testing Trait Method Inheritance...\n"; -$app = new ApplicationService(); - -// Valid call -$app->log('info', 'System booted'); -echo " ✅ Valid log('info', 'System booted') passed!\n"; - -// Invalid param: 'debug' is not in 'info'|'warning'|'error' -try { - $app->log('debug', 'System booted'); - echo " ❌ Failed to catch invalid log level on inherited trait method!\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -echo "\n🎉 BASELINE TEST COMPLETED!\n"; diff --git a/internals/test_extends_generics.php b/internals/test_extends_generics.php deleted file mode 100644 index 7c5f615..0000000 --- a/internals/test_extends_generics.php +++ /dev/null @@ -1,147 +0,0 @@ - - */ -class DogRepository extends Repository -{ - public function __construct() - { - // Passes null so constructor doesn't bind T automatically - parent::__construct(null); - } -} - -/** - * Fulfills T = Car via @extends - * - * @extends Repository - */ -class CarRepository extends Repository -{ - public function __construct() - { - // Passes null so constructor doesn't bind T automatically - parent::__construct(null); - } -} - -/** - * Fulfills T = Cat via @implements - * - * @implements ProcessorInterface - */ -class CatProcessor implements ProcessorInterface -{ - public function process(mixed $item): mixed - { - return $item; - } -} - -/** - * Accepts Repository - * - * @param Repository $repo - */ -function handleAnimalRepo(Repository $repo): mixed -{ - return $repo; -} - -/** - * Accepts ProcessorInterface - * - * @param ProcessorInterface $processor - */ -function handleAnimalProcessor(ProcessorInterface $processor): mixed -{ - return $processor; -} - -echo "=== Testing @extends and @implements Generic Annotations ===\n\n"; - -// ------------------------------------------------------------- -// 1. Testing @extends Repository vs Repository -// ------------------------------------------------------------- -echo "1. Testing @extends Repository (Valid)...\n"; - -try { - handleAnimalRepo(new DogRepository()); - echo " ✅ DogRepository passed for Repository!\n"; -} catch (TypeError $e) { - echo ' ❌ UNEXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -echo "\n2. Testing @extends Repository (Invalid - Car is not an Animal)...\n"; - -try { - handleAnimalRepo(new CarRepository()); - echo " ❌ Failed to catch invalid @extends Repository for Repository!\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -// ------------------------------------------------------------- -// 2. Testing @implements ProcessorInterface -// ------------------------------------------------------------- -echo "\n3. Testing @implements ProcessorInterface (Valid)...\n"; - -try { - handleAnimalProcessor(new CatProcessor()); - echo " ✅ CatProcessor passed for ProcessorInterface!\n"; -} catch (TypeError $e) { - echo ' ❌ UNEXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -echo "\n🎉 TEST RUN COMPLETED!\n"; diff --git a/internals/test_float_literals.php b/internals/test_float_literals.php deleted file mode 100644 index 9ed2dd7..0000000 --- a/internals/test_float_literals.php +++ /dev/null @@ -1,102 +0,0 @@ -getMessage() . "\n"; -} - -try { - testSimpleFloatLiteral(12.35); - echo " ❌ Failed to catch invalid float literal!\n"; -} catch (TypeError $e) { - echo " ✅ CAUGHT EXPECTED ERROR: " . $e->getMessage() . "\n"; -} - -// Test 2: Floating-Point Precision (0.1 + 0.2 = 0.30000000000000004) -echo "\n2. Testing Floating-Point Arithmetic Precision (0.1 + 0.2 vs @param 0.3)...\n"; -$sum = 0.1 + 0.2; // In IEEE 754 arithmetic, this evaluates to 0.30000000000000004 -echo " Computed sum (0.1 + 0.2): " . sprintf('%.17f', $sum) . "\n"; - -try { - testPrecisionFloatLiteral($sum); - echo " ✅ Passed float precision check!\n"; -} catch (TypeError $e) { - echo " ⚠️ CAUGHT STRICT MISMATCH (Float Precision Edge Case): " . $e->getMessage() . "\n"; -} - -// Test 3: Float Zero Literal (0.0 vs -0.0) -echo "\n3. Testing Float Zero (0.0 vs -0.0)...\n"; -try { - testFloatZeroLiteral(0.0); - echo " ✅ 0.0 passed!\n"; -} catch (TypeError $e) { - echo " ❌ ERROR: " . $e->getMessage() . "\n"; -} - -try { - testFloatZeroLiteral(-0.0); - echo " ✅ -0.0 passed!\n"; -} catch (TypeError $e) { - echo " ⚠️ CAUGHT STRICT MISMATCH for -0.0: " . $e->getMessage() . "\n"; -} - -// Test 4: Int vs Float Strictness (10 vs 10.0) -echo "\n4. Testing Int 10 passed to @param 10.0 (Float Literal)...\n"; -try { - testFloatVsIntLiteral(10); // Passing int 10 to float literal 10.0 - echo " ✅ Int 10 accepted for float literal 10.0!\n"; -} catch (TypeError $e) { - echo " ⚠️ CAUGHT STRICT TYPE MISMATCH (Int 10 vs Float 10.0): " . $e->getMessage() . "\n"; -} - -echo "\n🎉 FLOAT LITERAL TEST COMPLETED!\n"; \ No newline at end of file diff --git a/internals/test_generator_limitations.php b/internals/test_generator_limitations.php deleted file mode 100644 index a97f5f4..0000000 --- a/internals/test_generator_limitations.php +++ /dev/null @@ -1,94 +0,0 @@ - $items - */ -function processMultipleTimes(Traversable $items): int -{ - $count = 0; - - // First loop - foreach ($items as $k => $v) { - $count++; - } - - // Second loop (succeeds because IteratorProxy allows rewinding!) - foreach ($items as $k => $v) { - $count++; - } - - return $count; -} - -// ------------------------------------------------------------- -// 2. Countable & Method Forwarding -// ------------------------------------------------------------- -/** - * @param Traversable $items - */ -function processWithMethodCall(Traversable $items): int -{ - if ($items instanceof Countable) { - return $items->count(); - } - - return 0; -} - -// ------------------------------------------------------------- -// 3. Generator TSend Input Type Validation -// ------------------------------------------------------------- -/** - * TKey = int, TValue = string, TSend = positive-int, TReturn = void - * - * @return Generator - */ -function testSendGenerator(): Generator -{ - $receivedValue = yield 0 => 'first_value'; - yield 1 => "processed: {$receivedValue}"; -} - -echo "=== Testing Generator & Iterator Enhancements ===\n\n"; - -// TEST 1: Rewindability -echo "1. Testing Multiple Iteration on Wrapped Traversable (Rewindability)...\n"; -$iterator = new ArrayIterator(['a' => 10, 'b' => 20]); -$totalCount = processMultipleTimes($iterator); - -if ($totalCount === 4) { - echo " ✅ FIXED: IteratorProxy successfully allowed multiple iterations! (Count: {$totalCount})\n"; -} else { - echo " ❌ Failed rewindability test!\n"; -} - -// TEST 2: Countable & Method Forwarding -echo "\n2. Testing Countable & Method Forwarding on Wrapped Traversable...\n"; -$arrayIterator = new ArrayIterator(['a' => 10, 'b' => 20]); -$count = processWithMethodCall($arrayIterator); - -if ($count === 2) { - echo " ✅ FIXED: IteratorProxy successfully forwarded count() call! (Count: {$count})\n"; -} else { - echo " ❌ Failed Countable method test!\n"; -} - -// TEST 3: TSend Type Validation -echo "\n3. Testing \$gen->send() (TSend) Input Type Validation...\n"; -$gen = testSendGenerator(); -$gen->current(); // Reaches first yield - -try { - // Sends -500 (violating positive-int TSend contract) - $gen->send(-500); - echo " ❌ Failed to catch invalid TSend value!\n"; -} catch (TypeError $e) { - echo ' ✅ FIXED CAUGHT EXPECTED TSEND ERROR: ' . $e->getMessage() . "\n"; -} - -echo "\n🎉 ALL GENERATOR & ITERATOR ENHANCEMENTS PASSED PERFECTLY!\n"; diff --git a/internals/test_generic_bounds.php b/internals/test_generic_bounds.php deleted file mode 100644 index cf632a6..0000000 --- a/internals/test_generic_bounds.php +++ /dev/null @@ -1,120 +0,0 @@ -getMessage() . "\n"; -} - -// --- TEST 2: Class-Level Generic Bounds --- -echo "\n2. Testing Class-Level Generic Bounds...\n"; - -// Valid: AnimalContainer(Dog) -$dogBox = new AnimalContainer(new Dog()); -echo " ✅ AnimalContainer(Dog) passed!\n"; - -// Invalid: AnimalContainer(Car) -try { - $carBox = new AnimalContainer(new Car()); - echo " ❌ Failed to catch invalid class-level template bound for AnimalContainer(Car)!\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -// --- TEST 3: Return Type Fallback for Unbound Templates --- -echo "\n3. Testing Unbound Return Type Fallback...\n"; - -// Valid: Returns Dog (which is an Animal) -try { - createAnimal('dog'); - echo " ✅ createAnimal('dog') returned Dog (satisfies Animal bound)!\n"; -} catch (TypeError $e) { - echo ' ❌ UNEXPECTED ERROR on valid return: ' . $e->getMessage() . "\n"; -} - -// Invalid: Returns Car (not an Animal) -try { - createAnimal('car'); - echo " ❌ Failed to catch invalid return value for unbound @template T of Animal!\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -echo "\n🎉 ALL GENERIC BOUND TESTS COMPLETED!\n"; diff --git a/internals/test_nested_extends.php b/internals/test_nested_extends.php deleted file mode 100644 index 742f11e..0000000 --- a/internals/test_nested_extends.php +++ /dev/null @@ -1,146 +0,0 @@ -> - */ -class ProducerDogRepo extends Repository -{ - public function __construct() - { - parent::__construct(new Producer(new Dog())); - } -} - -/** - * 2. Invalid Nested Generic in @extends (Car is not an Animal) - * - * @extends Repository> - */ -class ProducerCarRepo extends Repository -{ - public function __construct() - { - parent::__construct(new Producer(new Car())); - } -} - -/** - * 3. Multi-level Generic Inheritance - * - * @template T - * - * @extends Repository - */ -abstract class BaseRepository extends Repository -{ -} - -/** - * @extends BaseRepository - */ -class MultiLevelDogRepo extends BaseRepository -{ - public function __construct() - { - parent::__construct(new Dog()); - } -} - -/** - * Accepts Repository> - * - * @param Repository> $repo - */ -function handleNestedRepo(Repository $repo): mixed -{ - return $repo->item; -} - -/** - * Accepts Repository - * - * @param Repository $repo - */ -function handleMultiLevelRepo(Repository $repo): mixed -{ - return $repo->item; -} - -echo "=== Testing Nested Generics & Multi-Level @extends ===\n\n"; - -// 1. Nested Generic in @extends -echo "1. Testing @extends Repository> (Valid)...\n"; - -try { - handleNestedRepo(new ProducerDogRepo()); - echo " ✅ ProducerDogRepo passed for Repository>!\n"; -} catch (TypeError $e) { - echo ' ❌ UNEXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -echo "\n2. Testing @extends Repository> (Invalid - Car is not an Animal)...\n"; - -try { - handleNestedRepo(new ProducerCarRepo()); - echo " ❌ Failed to catch invalid nested generic!\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -// 2. Multi-Level Generic Inheritance -echo "\n3. Testing Multi-Level @extends Inheritance (Dog -> BaseRepo -> Repository)...\n"; - -try { - handleMultiLevelRepo(new MultiLevelDogRepo()); - echo " ✅ MultiLevelDogRepo passed for Repository!\n"; -} catch (TypeError $e) { - echo ' ❌ UNEXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -echo "\n🎉 TEST RUN COMPLETED!\n"; diff --git a/internals/test_nested_generics.php b/internals/test_nested_generics.php deleted file mode 100644 index 4671d03..0000000 --- a/internals/test_nested_generics.php +++ /dev/null @@ -1,90 +0,0 @@ - or Producer - * - * @param Producer $producer - */ - public function processCovariant(Producer $producer) - { - return $producer->item; - } - - /** - * Contravariant self: accepts Producer or Producer - * - * @param Producer $producer - */ - public function processContravariant(Producer $producer) - { - return $producer->item; - } -} - -class SubNode extends Node -{ -} -class UnrelatedClass -{ -} - -echo "=== Testing with Generic Variance ===\n\n"; - -$node = new Node(); - -// ------------------------------------------------------------- -// 1. Covariant Tests -// ------------------------------------------------------------- -echo "1. Testing ...\n"; - -// Valid: SubNode extends Node (Covariant allows subtypes!) -$node->processCovariant(new Producer(new SubNode())); -echo " ✅ Producer passed for Producer!\n"; - -// Invalid: UnrelatedClass is not a Node -try { - $node->processCovariant(new Producer(new UnrelatedClass())); - echo " ❌ Failed to catch bad covariant argument!\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -// ------------------------------------------------------------- -// 2. Contravariant Tests -// ------------------------------------------------------------- -echo "\n2. Testing ...\n"; - -// Valid: BaseNode is a supertype of Node (Contravariant allows supertypes!) -$node->processContravariant(new Producer(new BaseNode())); -echo " ✅ Producer passed for Producer!\n"; - -// Invalid: SubNode is a subtype, not a supertype! -try { - $node->processContravariant(new Producer(new SubNode())); - echo " ❌ Failed to catch bad contravariant argument!\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -echo "\n🎉 VARIANCE WITH TEST PASSED PERFECTLY!\n"; diff --git a/internals/test_oop_types.php b/internals/test_oop_types.php deleted file mode 100644 index 301ad87..0000000 --- a/internals/test_oop_types.php +++ /dev/null @@ -1,122 +0,0 @@ -processUsers([new User(1, 'Alice'), new User(2, 'Bob')]); -echo " ✅ Valid User[] passed!\n"; - -try { - $processor->processUsers([new User(1, 'Alice'), 'not_a_user_object']); - echo " ❌ Failed to catch invalid User[]!\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -// ------------------------------------------------------------- -// 2. Testing array{id: int, name: string}[] -// ------------------------------------------------------------- -echo "\n2. Testing array{id: int, name: string}[]...\n"; -$processor->processUserShapes([ - ['id' => 1, 'name' => 'Alice'], - ['id' => 2, 'name' => 'Bob'], -]); -echo " ✅ Valid array{id: int, name: string}[] passed!\n"; - -try { - // Second shape is missing 'name' - $processor->processUserShapes([ - ['id' => 1, 'name' => 'Alice'], - ['id' => 2], - ]); - echo " ❌ Failed to catch invalid array shape in array!\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -// ------------------------------------------------------------- -// 3. Testing Shape with Object Array: array{users: User[], count: int} -// ------------------------------------------------------------- -echo "\n3. Testing array{users: User[], count: int}...\n"; -$processor->processPayload([ - 'users' => [new User(1, 'Alice')], - 'count' => 1, -]); -echo " ✅ Valid payload passed!\n"; - -try { - // 'users' contains a string instead of User object - $processor->processPayload([ - 'users' => ['not_a_user'], - 'count' => 1, - ]); - echo " ❌ Failed to catch invalid User object inside shape array!\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -// ------------------------------------------------------------- -// 4. Testing self[] -// ------------------------------------------------------------- -echo "\n4. Testing self[]...\n"; -$processor->getProcessors(); -echo " ✅ Valid self[] return passed!\n"; - -echo "\n🎉 ALL ARRAY & SHAPE TESTS PASSED PERFECTLY!\n"; diff --git a/internals/test_recursion_leak.php b/internals/test_recursion_leak.php deleted file mode 100644 index 4a13990..0000000 --- a/internals/test_recursion_leak.php +++ /dev/null @@ -1,84 +0,0 @@ -getMessage() . "\n"; -} catch (Throwable $e) { - echo ' ❌ UNEXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -// 2. Exception Recovery Test -echo "\n2. Testing Exception Recovery in Generic Function...\n"; - -try { - throwingGeneric(new Dog()); -} catch (RuntimeException $e) { - echo " -> Caught expected RuntimeException for Dog\n"; -} - -try { - throwingGeneric(new Cat()); -} catch (RuntimeException $e) { - echo " ✅ SUCCESS! Caught expected RuntimeException for Cat cleanly\n"; -} catch (TypeError $e) { - echo ' ❌ EXCEPTION LEAKAGE BUG DETECTED! ' . $e->getMessage() . "\n"; -} - -echo "\n🎉 CLI TEST COMPLETED!\n"; diff --git a/internals/test_std_class_shapes.php b/internals/test_std_class_shapes.php deleted file mode 100644 index ef2a9fa..0000000 --- a/internals/test_std_class_shapes.php +++ /dev/null @@ -1,23 +0,0 @@ -id = 100; -$std->name = 'Alice'; -testStrictStdClassShapeContract($std); - -$user = new UserObjectShape(100, 'Alice'); -testStrictStdClassShapeContract($user); // ❌ Throws TypeError! diff --git a/internals/test_type_aliases.php b/internals/test_type_aliases.php deleted file mode 100644 index cd4201a..0000000 --- a/internals/test_type_aliases.php +++ /dev/null @@ -1,103 +0,0 @@ -updateUser(['id' => 1, 'name' => 'Alice'], 'active'); - echo " ✅ Valid LocalStatus passed!\n"; -} catch (TypeError $e) { - echo ' ❌ UNEXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -try { - $service->updateUser(['id' => 1, 'name' => 'Alice'], 'invalid_status'); - echo " ❌ Failed to catch invalid LocalStatus!\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -// ------------------------------------------------------------- -// 2. Imported Type Aliases (@phpstan-import-type UserShape from GlobalTypes) -// ------------------------------------------------------------- -echo "\n2. Testing Imported Type Alias (@phpstan-import-type UserShape from GlobalTypes)...\n"; - -try { - // Invalid UserShape: id is negative (-1) - $service->updateUser(['id' => -1, 'name' => 'Alice'], 'active'); - echo " ❌ Failed to catch invalid imported UserShape!\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -// ------------------------------------------------------------- -// 3. Imported Type Aliases WITH 'as' Alias -// ------------------------------------------------------------- -echo "\n3. Testing Imported Type Alias with 'as' (@phpstan-import-type RoleType ... as UserRole)...\n"; - -try { - $service->setRole('admin'); - echo " ✅ Valid UserRole passed!\n"; -} catch (TypeError $e) { - echo ' ❌ UNEXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -try { - $service->setRole('superadmin'); - echo " ❌ Failed to catch invalid UserRole!\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -echo "\n🎉 BASELINE TEST COMPLETED!\n"; diff --git a/internals/test_variadic_templates.php b/internals/test_variadic_templates.php deleted file mode 100644 index aba3115..0000000 --- a/internals/test_variadic_templates.php +++ /dev/null @@ -1,34 +0,0 @@ - T is inferred as int -echo "1. Testing valid variadic template (all ints)...\n"; -collectSameType(10, 20, 30); -echo " ✅ Valid variadic template passed! (Inferred T = int)\n"; - -// 2. Invalid: First arg is int, 3rd arg is string -> Throws TypeError! -echo "\n2. Testing invalid variadic template (mixed int and string)...\n"; - -try { - collectSameType(10, 20, 'invalid_string'); - echo " ❌ Failed to catch inconsistent variadic template type!\n"; -} catch (TypeError $e) { - echo ' ✅ CAUGHT EXPECTED ERROR: ' . $e->getMessage() . "\n"; -} - -echo "\n🎉 VARIADIC GENERICS TEST PASSED PERFECTLY!\n"; diff --git a/internals/whitelisted_service.php b/internals/whitelisted_service.php deleted file mode 100644 index 8b8fb9b..0000000 --- a/internals/whitelisted_service.php +++ /dev/null @@ -1,15 +0,0 @@ - Date: Sat, 8 Aug 2026 20:21:00 +0800 Subject: [PATCH 14/15] Add execution order note to quick start guide for clarity on type hint evaluation --- docs/getting-started/quick-start.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index a5053ed..1015a86 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -74,6 +74,8 @@ processUser(-5, 'Alice'); // Throws: TypeError: processUser(): Argument $id must be of type positive-int, negative int (-5) given ``` +> **Execution Order Note:** Native PHP type hints (e.g., `int $id`, `string $username`) are evaluated by PHP's C-engine before function execution begins. TypePHP's extended DocBlock contracts (e.g., `positive-int`, `non-empty-string`) execute at function entry. If a native type hint fails, PHP throws its native `TypeError` before TypePHP guard rails execute. + --- ## Return Contracts (`@return`) @@ -224,3 +226,4 @@ namespace App\Legacy; > **Technical Note & Coding Convention:** > Under the hood, TypePHP scans the raw file contents for `@typephp-ignore-file` before performing AST transformations, meaning the tag will function regardless of its position in the file. However, you should always place `@typephp-ignore-file` at the very top of the file (right after ` Date: Sat, 8 Aug 2026 20:37:14 +0800 Subject: [PATCH 15/15] Enhance quick start guide with detailed explanations on TypePHP's functionality, recommended workflows, and progressive adoption strategies. --- docs/getting-started/quick-start.md | 48 ++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index 1015a86..b8701f9 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -1,13 +1,46 @@ # Quick Start Guide -TypePHP enforces PHPDoc type contracts at runtime. Below is an overview of core features and code examples. +TypePHP enforces PHPDoc type contracts at runtime during execution. Below is an overview of core concepts, framework setup, and code examples. -> **Recommended Workflow: PHPStan / Psalm / Mago / Phan + TypePHP** -> By design, TypePHP is a **runtime type enforcer, not a docblock linter or static analyzer**. For maximum execution performance, TypePHP gracefully ignores malformed docblock syntax and duplicate type alias declarations, focusing strictly on validating runtime data. -> -> It is highly recommended to use any static analyzer alongside TypePHP: -> * **PHPStan / Psalm / Mago / Phan (Compile-Time):** Lints your PHPDoc syntax, validates complex intersection rules, and catches static type errors in your IDE before code executes. -> * **TypePHP (Runtime):** Enforces those PHPDoc contracts during actual execution, ensuring your application against invalid API payloads, database records, and dynamic runtime data or making sure the doctypes will not lie to you at runtime. +--- + +## What is TypePHP? + +TypePHP is a transparent, pure-PHP runtime type checker that enforces extended PHPDoc type contracts (`@param`, `@return`, `@var`, `@template`, array shapes, integer ranges, and scalar refinements) during actual execution. + +Unlike traditional assertion libraries that force you to write repetitive manual check calls inside every function, or validation frameworks that require custom PHP attributes and base classes, TypePHP requires **zero manual checks** and **zero new syntax**. It works transparently using your existing PHPDoc annotations. + +--- + +## What Problem Does It Solve? + +While native PHP type hints (such as `int $id` or `string $name`) enforce basic scalar types, PHP's C-engine ignores PHPDoc annotations at runtime. This creates a dangerous safety gap at application boundaries: + +1. **Un-sanitized Dynamic Payloads:** HTTP API requests, Stripe webhooks, database query results, and JSON inputs frequently contain data that native PHP type hints allow through (such as passing a negative integer `-50` into a parameter expecting a `positive-int`). +2. **The "DocBlock Lie" Problem:** Developers write DocBlocks assuming they are accurate, but dynamic runtime callers can pass invalid data that bypasses native PHP type hints, polluting database state or causing silent bugs. +3. **Manual Validation Boilerplate:** Traditional runtime checkers force you to write imperative assertion calls (`Assert::positiveInteger($id)`) inside every function body or introduce custom attributes (`#[Validate]`). TypePHP removes all manual boilerplate by reading standard PHPDocs automatically. +4. **Boundary Testing in Pest & PHPUnit:** TypePHP physically verifies that your application boundaries withstand real-world dynamic data during local testing and CI/CD runs. + +--- + +## Progressive Adoption (Not All-or-Nothing) + +TypePHP does not force you into an "all-or-nothing" paradigm. You do not have to type-check your entire codebase or refactor legacy modules overnight. You can adopt TypePHP progressively at whatever granularity fits your project: + +1. **Path-Level Whitelisting:** Use `include` patterns in `typephp.php` to target specific mission-critical domain modules (such as `app/Domain/Billing/**`) while completely bypassing legacy directories. +2. **Method-Level Suppression:** Add `@typephp-ignore` to specific legacy methods or un-refactored functions without removing their PHPDoc annotations. +3. **Category-Level Feature Toggles:** Granularly enable or disable specific check categories (`inline_vars.scalars`, `inline_vars.arrays`, `params`, `returns`) in `typephp.php` depending on performance or migration needs. + +--- + +## Runtime Enforcement vs. Static Analysis (Partners, Not Replacements) + +TypePHP is **not a replacement** for static analysis tools like PHPStan, Psalm, Mago, or Phan. They are complementary partners designed to work together: + +* **Static Analysis (Compile-Time & IDE):** Lints your source code structure offline in your IDE and CI pipeline, catching static logic errors before your code ever runs. +* **TypePHP (Runtime & Execution):** Validates actual dynamic data in RAM during execution, ensuring that incoming API payloads, database results, and test suite inputs strictly satisfy your PHPDoc contracts. + +> **Recommended Workflow:** Use PHPStan or Psalm in your IDE to lint code syntax, and use TypePHP during Pest/PHPUnit test runs and CI pipelines to guarantee runtime data integrity. --- @@ -226,4 +259,3 @@ namespace App\Legacy; > **Technical Note & Coding Convention:** > Under the hood, TypePHP scans the raw file contents for `@typephp-ignore-file` before performing AST transformations, meaning the tag will function regardless of its position in the file. However, you should always place `@typephp-ignore-file` at the very top of the file (right after `