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/.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/.vitepress/config.mts b/docs/.vitepress/config.mts new file mode 100644 index 0000000..6f66fc6 --- /dev/null +++ b/docs/.vitepress/config.mts @@ -0,0 +1,77 @@ +import { defineConfig } from 'vitepress' + +export default defineConfig({ + title: "TypePHP", + 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-php/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: 'Architecture', + items: [ + { text: 'How It Works', link: '/architecture/how-it-works' }, + ] + }, + { + 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: 'Unions, Intersections & Conditionals', link: '/supported-types/unions-intersections-and-conditionals' }, + ] + }, + { + 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: 'Troubleshooting & FAQ', link: '/advanced/troubleshooting' } + ] + }, + { + text: 'Production & Performance', + items: [ + { text: 'Production Readiness', link: '/production/production-readiness' }, + { text: 'Cache CLI Commands', link: '/production/cache-commands' }, + { text: 'Performance Considerations', link: '/production/performance-considerations' }, + ] + } + ], + socialLinks: [ + { icon: 'github', link: 'https://github.com/typephp-php/typephp' } + ], + search: { + provider: 'local' + } + } +}) \ No newline at end of file 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/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 new file mode 100644 index 0000000..372cceb --- /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)`): + +**`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). +* **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` -> **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/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/core-concepts/function-contracts.md b/docs/core-concepts/function-contracts.md new file mode 100644 index 0000000..ec32f44 --- /dev/null +++ b/docs/core-concepts/function-contracts.md @@ -0,0 +1,276 @@ +# 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 +``` +--- + +## 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/core-concepts/generics-and-bounds.md b/docs/core-concepts/generics-and-bounds.md new file mode 100644 index 0000000..2a70a82 --- /dev/null +++ b/docs/core-concepts/generics-and-bounds.md @@ -0,0 +1,796 @@ +# 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 +``` + +--- + +## Reified Generics API (Kind of) + +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(); + +// Single-Template Smart Fallback (No template name needed!) +$userType = TypePHP::getGenericType(object: $users); // Returns 'App\Models\User' + +// Multi-Template Explicit Inspection +$keyType = TypePHP::getGenericType(object: $catalog, template: 'K'); // Returns 'string' +$valueType = TypePHP::getGenericType(object: $catalog, template: 'V'); // Returns 'App\Models\Product' + +// Inherited Generic Classes (@extends BaseRepository) +$userRepo = new UserRepository(); +$repoType = TypePHP::getGenericType(object: $userRepo); // Returns 'App\Models\User' + +// 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 + +* **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. + +--- + +## 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 new file mode 100644 index 0000000..fd4232b --- /dev/null +++ b/docs/getting-started/configuration.md @@ -0,0 +1,135 @@ +# 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/**', + ], +]; +``` + +--- + +## Inline Variable Categories Reference (`inline_vars`) + +How each `inline_vars` toggle maps to PHPDoc type annotations: + +| 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'` | + +### Important Notes on `inline_vars` Behavior + +* **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`! + +--- + +## 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..b38b2cf --- /dev/null +++ b/docs/getting-started/installation.md @@ -0,0 +1,80 @@ +# Installation + +TypePHP requires **PHP 8.1 or higher**. + +--- + +## 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 +``` + +--- + +## 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: + +```bash +vendor/bin/typephp config:init +``` + +--- + +## Executing Individual Scripts via CLI + +To execute a standalone PHP script with native PHP execution and active TypePHP runtime enforcement: + +```bash +vendor/bin/typephp index.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. + +--- + +## Autoloading & Bootstrapping + +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: + +```php + **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. + +--- + +## Execution & Framework Entry Points + +Because TypePHP automatically integrates with Composer's autoloader (`vendor/autoload.php`), you don't always need to use the custom CLI runner. + +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 + **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`) + +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 negative + } + + 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 +``` + +--- + +## 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 & Asymmetric Visibility + +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 +``` + +--- + +## 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 +``` + +### File-Level Suppression + +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 ` $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 +``` + +### Array Shapes & Typed Arrays +Enforce strict associative array structures and collections of specific objects: + +```php +/** + * @param array{status: 'active'|'pending', tags: list} $options + * @param User[] $collaborators + */ +function processBatch(array $options, array $collaborators): void +{ + // ... +} + +processBatch( + options: ['status' => 'active', 'tags' => ['php', 'types']], + collaborators: [new User(), new User()] +); // Valid + +processBatch( + options: ['status' => 'archived', 'tags' => ['php']], + collaborators: [] +); +// Throws 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: + +```php +/** + * @param positive-int $id + * @return non-empty-string + */ +function generateUserToken(int $id): string +{ + return ""; // Throws TypeError: Return value must be of type non-empty-string +} + +generateUserToken(-5); +// Throws TypeError: Argument $id must be of type positive-int, negative int (-5) given +``` + +--- + +## Precise Stack Trace & Error Reporting + +TypePHP injects single-line guard rails without shifting your source file line numbers. + +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 + + 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..c059981 --- /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! \ No newline at end of file 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/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/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/docs/supported-types/primitives-and-scalars.md b/docs/supported-types/primitives-and-scalars.md new file mode 100644 index 0000000..a8ce431 --- /dev/null +++ b/docs/supported-types/primitives-and-scalars.md @@ -0,0 +1,322 @@ +# Primitives & Scalars + +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. + +--- + +## 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 +{ + // ... +} +``` + +--- + +## 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 <= 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`) + +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 <= 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 +/** + * @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/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 +``` 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-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 @@ -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-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.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_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 @@ - $args * @param resource $outputStream @@ -20,10 +17,14 @@ public static function run(array $args, $outputStream = STDOUT, $errorStream = S { $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)) { + return (new ConfigInitCommand())->execute($args, $outputStream, $errorStream); + } + if (\in_array('cache:rebuild', $args, true)) { return (new CacheRebuildCommand())->execute($args, $outputStream, $errorStream); } diff --git a/src/Command/ConfigInitCommand.php b/src/Command/ConfigInitCommand.php new file mode 100644 index 0000000..bee88d3 --- /dev/null +++ b/src/Command/ConfigInitCommand.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; + } +} diff --git a/src/Command/HelpCommand.php b/src/Command/HelpCommand.php index 771257f..56d466e 100644 --- a/src/Command/HelpCommand.php +++ b/src/Command/HelpCommand.php @@ -4,9 +4,6 @@ namespace TypePHP\Command; -/** - * @internal Displays TypePHP CLI help menu. - */ final class HelpCommand implements CommandInterface { public function execute(array $args, $outputStream = STDOUT, $errorStream = STDERR): int @@ -17,11 +14,13 @@ public function execute(array $args, $outputStream = STDOUT, $errorStream = STDE 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, " vendor/bin/typephp config:init\n"); fwrite($outputStream, " vendor/bin/typephp index.php\n"); fwrite($outputStream, " vendor/bin/typephp cache:rebuild\n\n"); 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 28f14ae..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); } } @@ -131,10 +133,33 @@ 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|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\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), + ] + ); + } + if ($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod || $node instanceof Node\Expr\Closure diff --git a/src/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index cc449ba..41cf654 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. */ 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 1e68ead..2147d6a 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,87 @@ 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 []; + } + + /** + * 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. */ 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 +217,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; } @@ -268,7 +378,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; } diff --git a/src/TypePHP.php b/src/TypePHP.php index 90f6009..0107094 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,76 @@ 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 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/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/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 6ffa92d..bab3ba0 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, @@ -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)), @@ -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)), 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 d1f7c62..bd40b0c 100644 --- a/tests/Command/CommandRunnerTest.php +++ b/tests/Command/CommandRunnerTest.php @@ -20,6 +20,19 @@ ; }); + 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 () { $stream = fopen('php://memory', 'r+'); $exitCode = CommandRunner::run(['cache:clear'], $stream, $stream); diff --git a/tests/Fixtures/Generics/GenericBox.php b/tests/Fixtures/Generics/GenericBox.php new file mode 100644 index 0000000..67268ac --- /dev/null +++ b/tests/Fixtures/Generics/GenericBox.php @@ -0,0 +1,24 @@ +item = $item; + } +} diff --git a/tests/Fixtures/Generics/GenericBoxWithMagicClone.php b/tests/Fixtures/Generics/GenericBoxWithMagicClone.php new file mode 100644 index 0000000..e3905a1 --- /dev/null +++ b/tests/Fixtures/Generics/GenericBoxWithMagicClone.php @@ -0,0 +1,31 @@ +item = $item; + } + + public function __clone(): void + { + $this->item = new Dog(); + } +} 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/Oop/ChildClassInheritingTraitParent.php b/tests/Fixtures/Oop/ChildClassInheritingTraitParent.php new file mode 100644 index 0000000..797825a --- /dev/null +++ b/tests/Fixtures/Oop/ChildClassInheritingTraitParent.php @@ -0,0 +1,10 @@ + $this->_unvalidatedVal; + set => $this->_unvalidatedVal = $value; + } + + public int $_unvalidatedVal = 10; +} \ No newline at end of file diff --git a/tests/SomeTest.php b/tests/SomeTest.php index 7d889ce..27e4116 100644 --- a/tests/SomeTest.php +++ b/tests/SomeTest.php @@ -5,4 +5,6 @@ // 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 new file mode 100644 index 0000000..a50c0c9 --- /dev/null +++ b/tests/TypeChecking/CloneGenericInstanceTest.php @@ -0,0 +1,78 @@ + $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'); + }); +}); 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/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 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/OopInheritanceTest.php b/tests/TypeChecking/OopInheritanceTest.php index bc8f123..f9c4157 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; @@ -52,4 +53,13 @@ ->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'); + }); }); diff --git a/tests/TypeChecking/PhpAttributesCoexistenceTest.php b/tests/TypeChecking/PhpAttributesCoexistenceTest.php new file mode 100644 index 0000000..df058c3 --- /dev/null +++ b/tests/TypeChecking/PhpAttributesCoexistenceTest.php @@ -0,0 +1,67 @@ +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') + ; + }); +}); 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 diff --git a/tests/Unit/TypePHPTest.php b/tests/Unit/TypePHPTest.php index 3f567d8..06d0f9b 100644 --- a/tests/Unit/TypePHPTest.php +++ b/tests/Unit/TypePHPTest.php @@ -4,8 +4,36 @@ namespace TypePHP\Tests\Unit; +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; +/** + * @template ItemType + */ +class CustomTemplateNameBox +{ + /** + * @var ItemType + */ + 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(); @@ -39,4 +67,64 @@ TypePHP::resetConfig(); expect(TypePHP::getConfig()['cache'])->toBeTrue(); }); + + 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(); + + 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']); + }); });