diff --git a/.gitattributes b/.gitattributes index 3a521d8..02f85e0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,3 +7,9 @@ /phpunit.xml export-ignore /pint.json export-ignore /typephp.php export-ignore +/mago.toml export-ignore +/psalm.xml export-ignore +/.php-cs-fixer.dist.php export-ignore +/codecov.yml export-ignore +/rector.php export-ignore +/typephp.php export-ignore \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 668c712..4fd3df5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,7 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} files: clover.xml - fail_ci_if_error: true + fail_ci_if_error: false - name: Run Test Suite (Pest) run: ./vendor/bin/pest --compact diff --git a/.gitignore b/.gitignore index 3057c4e..c814674 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ /var /manual-tests composer.lock -index.php \ No newline at end of file +index.php +.php-cs-fixer.cache \ No newline at end of file diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..3e8ba68 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,16 @@ +in(__DIR__ . '/src') + ->exclude('vendor'); + +return (new Config()) + ->setRules([ + '@PSR12' => true, + 'array_syntax' => ['syntax' => 'short'], + 'ordered_imports' => true, + ]) + ->setFinder($finder) + ->setRiskyAllowed(true); \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9ac771f..e36f449 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,26 +1,47 @@ # Contributing to TypePHP -Thank you for showing interest in contributing to this TypePHP library! Contributions are essential for building a robust type-safe ecosystem for the PHP community. +Thank you for showing interest in contributing to TypePHP! Contributions are essential for building a robust, type-safe ecosystem for the PHP community. -This library is designed to be a reliable foundation for high-performance applications. To achieve this, it maintains rigorous standards for code quality and developer experience. +This library is designed to be a reliable foundation for high-performance applications. To achieve this, it maintains rigorous standards for code quality, developer experience, and static analysis compatibility. + +--- ## Development Workflow -To ensure consistency across the ecosystem, this repository requires the following workflow: +To ensure consistency across the codebase, this repository requires the following workflow: 1. **Fork and Branch**: Fork the repository and create a feature branch from `main`. 2. **Dependencies**: Install development tools using `composer install`. -3. **Coding Standards**: This project follows strict PSR-12 standards. Use Laravel Pint to format code: `./vendor/bin/pint`. -4. **Static Analysis**: Code must be predictable and type-safe. It must pass PHPStan at the maximum level: `./vendor/bin/phpstan analyse`. -5. **Testing**: This project uses Pest. Ensure the test suite passes completely: `./vendor/bin/pest`. +3. **Linting & Code Formatting Authority (Laravel Pint)**: This project follows strict PSR-12 standards. Laravel Pint is the **sole authoritative linter and formatter** for the entire codebase: + ```bash + ./vendor/bin/pint + ``` +4. **Static Analysis Authority (PHPStan)**: Code must pass **PHPStan at Level MAX** (`treatPhpDocTypesAsCertain: false`): + ```bash + ./vendor/bin/phpstan analyse + ``` +5. **Testing**: This project uses Pest. Ensure all tests pass completely: + ```bash + ./vendor/bin/pest + ``` 6. **Strict Typing**: Every PHP file must begin with `declare(strict_types=1);`. +--- + +## Tooling Authority & Interoperability Policy + +* **Laravel Pint is the Authoritative Linter & Formatter**: All code styling and linting rules are defined strictly in `pint.json`. No external style linter overrides Pint. +* **PHPStan is the Authoritative Static Analyzer**: PHPStan configured at Level MAX is the official gatekeeper for type safety and code quality in TypePHP. All contributions must pass PHPStan checks without errors. +* **Tooling Interoperability (Psalm, Mago, Rector, PHP-CS-Fixer, etc.)**: Secondary analyzers and tools (such as Psalm, Mago, Rector, and PHP-CS-Fixer) are integrated into the test environment solely for **interoperability verification** and ensuring that TypePHP's runtime stream wrapper and AST transformations stand down properly and do not deadlock or conflict with external static analysis engines. + +--- ## Pull Request Process -1. **Start with an Issue**: Before writing code, please open an issue to discuss the bug or the proposed feature. -2. **Tests are Required**: Every Pull Request must include automated tests that cover the new logic or prevent the bug from recurring. +1. **Start with an Issue**: Before writing code, please open an issue to discuss the bug or proposed feature. +2. **Tests are Required**: Every Pull Request must include automated Pest tests that cover the new logic and prevent regressions. +3. **Keep Code Clean**: Run `./vendor/bin/pint`, `./vendor/bin/phpstan analyse`, and `./vendor/bin/pest` before submitting your PR. --- -The Hibla ecosystem thanks you for your time and effort! \ No newline at end of file +The TypePHP ecosystem thanks you for your time and effort! \ No newline at end of file diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..c6760f2 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,12 @@ +coverage: + status: + project: + default: + enabled: false + patch: + default: + enabled: false + +comment: + layout: "reach,diff,flags,files,footer" + behavior: default \ No newline at end of file diff --git a/composer.json b/composer.json index 2b49289..45d0b4a 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,11 @@ "pestphp/pest": "^2.0 || ^3.0 || ^4.0", "phpstan/phpstan": "^2.1", "phpstan/phpstan-strict-rules": "^2.0", - "phpstan/extension-installer": "^1.4" + "phpstan/extension-installer": "^1.4", + "vimeo/psalm": "^6.16", + "carthage-software/mago": "^1.47", + "rector/rector": "^2.6", + "friendsofphp/php-cs-fixer": "^3.95" }, "bin": [ "bin/typephp" @@ -62,8 +66,20 @@ "test": [ "./vendor/bin/pest --colors" ], - "analyse": [ - "./vendor/bin/phpstan analyse" + "analyze": [ + "./vendor/bin/phpstan analyze" + ], + "psalm": [ + "./vendor/bin/psalm" + ], + "mago": [ + "./vendor/bin/mago analyze" + ], + "rector": [ + "./vendor/bin/rector process" + ], + "rector:dry": [ + "./vendor/bin/rector process --dry-run" ] }, "minimum-stability": "dev", @@ -74,4 +90,4 @@ "phpstan/extension-installer": true } } -} \ No newline at end of file +} diff --git a/mago.toml b/mago.toml new file mode 100644 index 0000000..3936369 --- /dev/null +++ b/mago.toml @@ -0,0 +1,7 @@ +php-version = "8.1.0" + +[source] +paths = ["src"] +includes = [] +excludes = ["tests/**", "vendor/**", "storage/**", "var/**", "cache/**"] + diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..c87a444 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..d4ee0bc --- /dev/null +++ b/rector.php @@ -0,0 +1,16 @@ +withPaths([ + __DIR__ . '/src', + ]) + ->withoutParallel() + ->withRules([ + AddNameToBooleanArgumentRector::class, + ]) +; diff --git a/src/Command/CliFormatter.php b/src/Command/CliFormatter.php index 895724d..df4449d 100644 --- a/src/Command/CliFormatter.php +++ b/src/Command/CliFormatter.php @@ -15,8 +15,8 @@ public static function initVT100(): void { if (! self::$vt100Initialized) { if (\function_exists('sapi_windows_vt100_support')) { - @sapi_windows_vt100_support(STDOUT, true); - @sapi_windows_vt100_support(STDERR, true); + @sapi_windows_vt100_support(STDOUT, enable: true); + @sapi_windows_vt100_support(STDERR, enable: true); } self::$vt100Initialized = true; } diff --git a/src/Command/CommandRunner.php b/src/Command/CommandRunner.php index a9091ca..0d17e83 100644 --- a/src/Command/CommandRunner.php +++ b/src/Command/CommandRunner.php @@ -25,10 +25,10 @@ public static function run(array $args, $outputStream = STDOUT, $errorStream = S { $c = [CliFormatter::class, 'color']; - $showHelp = \in_array('help', $args, true) - || \in_array('typephp:help', $args, true) - || \in_array('--help', $args, true) - || \in_array('-h', $args, true) + $showHelp = \in_array('help', $args, strict: true) + || \in_array('typephp:help', $args, strict: true) + || \in_array('--help', $args, strict: true) + || \in_array('-h', $args, strict: true) || $args === []; if ($showHelp) { diff --git a/src/Command/RunCommand.php b/src/Command/RunCommand.php index 67bc72c..ffb3b55 100644 --- a/src/Command/RunCommand.php +++ b/src/Command/RunCommand.php @@ -25,7 +25,7 @@ public function execute(array $args, $outputStream = STDOUT, $errorStream = STDE $givenTargetCandidate = $arg; $ext = strtolower(pathinfo($arg, PATHINFO_EXTENSION)); - if ($ext !== '' && ! \in_array($ext, self::VALID_PHP_EXTENSIONS, true)) { + if ($ext !== '' && ! \in_array($ext, self::VALID_PHP_EXTENSIONS, strict: true)) { fwrite($errorStream, "\n " . $c(' TYPEPHP ', 'badge_red') . ' ' . $c('Error', 'bold') . "\n\n"); fwrite($errorStream, ' ' . $c('✗', 'red') . ' Target file ' . $c('"' . $arg . '"', 'bold') . " is not a PHP script file. TypePHP can only execute PHP files.\n\n"); diff --git a/src/Contract/FileFilter.php b/src/Contract/FileFilter.php index f6289cc..c6d1189 100644 --- a/src/Contract/FileFilter.php +++ b/src/Contract/FileFilter.php @@ -4,8 +4,8 @@ namespace TypePHP\Contract; -use TypePHP\Internal\CacheManager; use TypePHP\Internal\Config; +use TypePHP\Internal\PathMatcher; /** * @internal Checks file paths against vendor directories, file extensions, and user-configured include/exclude globs. @@ -19,34 +19,13 @@ final class FileFilter */ private static array $pathFilterCache = []; - /** - * Pre-compiled include regex patterns, raw patterns, and match lengths. - * - * @var array|null - */ - private static ?array $compiledIncludes = null; - - /** - * Pre-compiled exclude regex patterns, raw patterns, and match lengths. - * - * @var array|null - */ - private static ?array $compiledExcludes = null; - - /** - * Cached normalized cache directory path. - */ - private static ?string $cachedCacheDir = null; - /** * Resets the path decision cache and pre-compiled regex patterns. Useful for test isolation. */ public static function reset(): void { self::$pathFilterCache = []; - self::$compiledIncludes = null; - self::$compiledExcludes = null; - self::$cachedCacheDir = null; + PathMatcher::reset(); } /** @@ -59,7 +38,7 @@ public static function isFileExcluded(string|false|null $fileName): bool return false; } - $normalizedPath = str_replace('\\', '/', $fileName); + $normalizedPath = PathMatcher::normalizePath($fileName); if (isset(self::$pathFilterCache[$normalizedPath])) { return self::$pathFilterCache[$normalizedPath]; @@ -70,122 +49,18 @@ public static function isFileExcluded(string|false|null $fileName): bool return self::$pathFilterCache[$normalizedPath] = true; } - if (self::$cachedCacheDir === null) { - self::$cachedCacheDir = rtrim(str_replace('\\', '/', CacheManager::getCacheDir()), '/') . '/'; - } - - if (str_starts_with($normalizedPath, self::$cachedCacheDir)) { + if (PathMatcher::isCachePath($normalizedPath)) { return self::$pathFilterCache[$normalizedPath] = true; } - if (self::$compiledIncludes === null || self::$compiledExcludes === null) { - self::compilePatterns(); - } - - $isVendorPath = str_contains($normalizedPath, '/vendor/'); - if ($isVendorPath) { - $hasExplicitVendorWhitelist = false; - /** @var array $includes */ - $includes = self::$compiledIncludes; - foreach ($includes as $compiled) { - if (str_starts_with($compiled['pattern'], 'vendor/') && preg_match($compiled['regex'], $normalizedPath) === 1) { - $hasExplicitVendorWhitelist = true; - - break; - } - } - - if (! $hasExplicitVendorWhitelist) { - return self::$pathFilterCache[$normalizedPath] = true; // Instantly exclude! - } - } - - $longestIncludeMatch = 0; - /** @var array $includes */ - $includes = self::$compiledIncludes; - foreach ($includes as $compiled) { - $isExplicitVendorInclude = str_starts_with($compiled['pattern'], 'vendor/'); - $isWildcard = ($compiled['pattern'] === '*' || $compiled['pattern'] === '**'); - - if ($isVendorPath && ! $isExplicitVendorInclude && ! $isWildcard) { - continue; - } - - if (preg_match($compiled['regex'], $normalizedPath) === 1) { - $longestIncludeMatch = max($longestIncludeMatch, $compiled['len']); - } - } - - $longestExcludeMatch = 0; - /** @var array $excludes */ - $excludes = self::$compiledExcludes; - foreach ($excludes as $compiled) { - if (preg_match($compiled['regex'], $normalizedPath) === 1) { - $longestExcludeMatch = max($longestExcludeMatch, $compiled['len']); - } - } - - // Equal specificity tie-breaker: Exclude wins! - return self::$pathFilterCache[$normalizedPath] = ($longestExcludeMatch >= $longestIncludeMatch); - } - - /** - * Compiles configured include and exclude globs into regex patterns once per configuration lifecycle. - */ - private static function compilePatterns(): void - { $config = Config::get(); - /** @var array $includes */ + /** @var array $includes */ $includes = \is_array($config['include'] ?? null) ? $config['include'] : ['**']; - /** @var array $excludes */ + /** @var array $excludes */ $excludes = \is_array($config['exclude'] ?? null) ? $config['exclude'] : ['vendor/**', 'storage/**', 'var/**', 'cache/**']; - $baseDir = Config::getProjectRoot(); - - self::$compiledIncludes = []; - foreach ($includes as $pattern) { - if (\is_string($pattern)) { - $trimmed = trim($pattern); - self::$compiledIncludes[] = [ - 'pattern' => $trimmed, - 'len' => \strlen($trimmed), - 'regex' => self::compileGlobToRegex($trimmed, $baseDir), - ]; - } - } - - self::$compiledExcludes = []; - foreach ($excludes as $pattern) { - if (\is_string($pattern)) { - $trimmed = trim($pattern); - self::$compiledExcludes[] = [ - 'pattern' => $trimmed, - 'len' => \strlen($trimmed), - 'regex' => self::compileGlobToRegex($trimmed, $baseDir), - ]; - } - } - } - - /** - * Converts a glob pattern into an absolute regex pattern. - */ - private static function compileGlobToRegex(string $glob, string $baseDir): string - { - $glob = str_replace('\\', '/', trim($glob)); - $isAbsolute = str_starts_with($glob, '/') || (bool) preg_match('#^[a-zA-Z]:/#', $glob); - - $regex = preg_quote($glob, '#'); - $regex = str_replace(['\*\*', '\*'], ['.*', '[^/]*'], $regex); - - if ($isAbsolute) { - $pattern = '^' . $regex . '$'; - } elseif ($glob === '*' || $glob === '**' || str_starts_with($glob, '**')) { - $pattern = '.*' . ($glob === '*' || $glob === '**' ? '' : substr($regex, 4)) . '$'; - } else { - $pattern = '(^' . preg_quote($baseDir . '/', '#') . '|^.*\/)' . $regex . '$'; - } + $isIncluded = PathMatcher::isPathIncluded($normalizedPath, $includes, $excludes, $fileName); - return '#' . $pattern . '#i'; + return self::$pathFilterCache[$normalizedPath] = ! $isIncluded; } } diff --git a/src/Extension/ExtensionManager.php b/src/Extension/ExtensionManager.php index 2b8de43..a9f62f9 100644 --- a/src/Extension/ExtensionManager.php +++ b/src/Extension/ExtensionManager.php @@ -24,7 +24,7 @@ public static function loadExtensionIncludes(array $configuredExtensions = []): $uniqueExtensions = array_unique($configuredExtensions); foreach ($uniqueExtensions as $extensionClass) { - if (\is_string($extensionClass) && class_exists($extensionClass) && is_a($extensionClass, ExtensionInterface::class, true)) { + if (\is_string($extensionClass) && class_exists($extensionClass) && is_a($extensionClass, ExtensionInterface::class, allow_string: true)) { /** @var ExtensionInterface $instance */ $instance = new $extensionClass(); $config = $instance->getConfig(); diff --git a/src/Internal/CacheManager.php b/src/Internal/CacheManager.php index 28684f3..253bb8c 100644 --- a/src/Internal/CacheManager.php +++ b/src/Internal/CacheManager.php @@ -119,7 +119,7 @@ public static function warmUp(?callable $progressCallback = null): array $transformed = StreamWrapper::transformSource($source, $file); $cacheDir = self::getCacheDir(); if (! is_dir($cacheDir)) { - @mkdir($cacheDir, 0777, true); + @mkdir($cacheDir, 0777, recursive: true); } file_put_contents($cachedFile, $transformed); $cached++; diff --git a/src/Internal/Checker/InlineChecker.php b/src/Internal/Checker/InlineChecker.php index 7eb230c..918e410 100644 --- a/src/Internal/Checker/InlineChecker.php +++ b/src/Internal/Checker/InlineChecker.php @@ -124,7 +124,7 @@ public static function checkVariable(mixed $value, string $typeString, string $v $checkGenerics = (bool) ($config['generics'] ?? true); if ($typeNode instanceof GenericTypeNode && $checkGenerics && \is_object($value)) { - $err = TemplateManager::bindInstanceFromNode($value, $typeNode, $context, true); + $err = TemplateManager::bindInstanceFromNode($value, $typeNode, $context, forceBind: true); if ($err !== null) { return $err; } diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index 84d28e1..858eb01 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -227,7 +227,7 @@ private static function inferFromTypeNode( ): void { if ($typeNode instanceof GenericTypeNode) { $baseType = strtolower($typeNode->type->name); - if (! \in_array($baseType, ['array', 'list', 'iterable', 'traversable'], true)) { + if (! \in_array($baseType, ['array', 'list', 'iterable', 'traversable'], strict: true)) { return; } @@ -438,7 +438,7 @@ private static function resolveClassStringTemplate( $boundName = $resolvedBound instanceof IdentifierTypeNode ? $resolvedBound->name : (string) $resolvedBound; $lowerBound = strtolower($boundName); - if ($lowerBound !== 'object' && $lowerBound !== 'mixed' && ! is_a($val, $boundName, true)) { + if ($lowerBound !== 'object' && $lowerBound !== 'mixed' && ! is_a($val, $boundName, allow_string: true)) { return ErrorFactory::createError($function . '(): Argument $' . $paramName . ' (class-string<' . $templateName . '>) must be a class-string of ' . $boundName . ", '" . $val . "' given"); } } @@ -448,7 +448,7 @@ private static function resolveClassStringTemplate( $expectedTypeNode = TemplateManager::getBoundType($function, $thisObj, $templateName); $targetClass = $expectedTypeNode instanceof IdentifierTypeNode ? $expectedTypeNode->name : (string) $expectedTypeNode; - if (! \is_string($val) || ! is_a($val, $targetClass, true)) { + if (! \is_string($val) || ! is_a($val, $targetClass, allow_string: true)) { $valStr = TypeFormatter::formatGivenValue($val); return ErrorFactory::createError($function . '(): Argument $' . $paramName . ' must be a class-string of ' . $targetClass . ', ' . $valStr . ' given'); diff --git a/src/Internal/Checker/ReturnChecker.php b/src/Internal/Checker/ReturnChecker.php index 4e98162..37b8400 100644 --- a/src/Internal/Checker/ReturnChecker.php +++ b/src/Internal/Checker/ReturnChecker.php @@ -230,7 +230,7 @@ private static function evaluateReturn( } $genericIterables = ['iterable', 'traversable', 'iterator', 'generator']; - if (\in_array($baseName, $genericIterables, true)) { + if (\in_array($baseName, $genericIterables, strict: true)) { return $wrapIterableCallback($function, 'return', $value); } } @@ -314,7 +314,7 @@ private static function resolveTemplateConditional( $isTargetMatch = ClassNameValidator::isValid($subStr) && ClassNameValidator::isValid($targetStr) && (class_exists($subStr) || interface_exists($subStr)) && (class_exists($targetStr) || interface_exists($targetStr)) && - is_a($subStr, $targetStr, true); + is_a($subStr, $targetStr, allow_string: true); } if ($node->negated) { diff --git a/src/Internal/Config.php b/src/Internal/Config.php index 01e0eb7..3055986 100644 --- a/src/Internal/Config.php +++ b/src/Internal/Config.php @@ -211,6 +211,7 @@ public static function set(array $config): void ContractParser::reset(); FileFilter::reset(); + PathMatcher::reset(); StreamWrapper::reset(); } @@ -232,6 +233,7 @@ public static function reset(): void TemplateManager::reset(); HierarchyResolver::reset(); FileFilter::reset(); + PathMatcher::reset(); StreamWrapper::reset(); } diff --git a/src/Internal/ContractVisitor.php b/src/Internal/ContractVisitor.php index 17c469a..29c97c0 100644 --- a/src/Internal/ContractVisitor.php +++ b/src/Internal/ContractVisitor.php @@ -106,7 +106,7 @@ public function enterNode(Node $node): array|int|null NodeBuilder::createTernaryThrowExpr($checkCall, $dVar['expr']->getStartLine()) ) ); - $checkStmt->setAttribute('typephp_injected', true); + $checkStmt->setAttribute('typephp_injected', value: true); $checkStmts[] = $checkStmt; } } @@ -166,7 +166,7 @@ public function leaveNode(Node $node): Node|null return null; } - $node->setAttribute('typephp_wrapped', true); + $node->setAttribute('typephp_wrapped', value: true); return new Node\Expr\FuncCall( new Node\Name('\TypePHP\Internal\RuntimeTypeChecker::cloneInstance'), diff --git a/src/Internal/DocblockNormalizer.php b/src/Internal/DocblockNormalizer.php index f5ca2cb..602d75a 100644 --- a/src/Internal/DocblockNormalizer.php +++ b/src/Internal/DocblockNormalizer.php @@ -43,7 +43,7 @@ function (array $matches): string { $shapeBody = $matches[2]; $lower = strtolower(ltrim($className, '\\')); - if (\in_array($lower, self::BUILTIN_SHAPE_KEYWORDS, true)) { + if (\in_array($lower, self::BUILTIN_SHAPE_KEYWORDS, strict: true)) { return $matches[0]; } diff --git a/src/Internal/PathMatcher.php b/src/Internal/PathMatcher.php new file mode 100644 index 0000000..361a2a1 --- /dev/null +++ b/src/Internal/PathMatcher.php @@ -0,0 +1,250 @@ +> + */ + private static array $compiledIncludesCache = []; + + /** + * Cache for compiled exclude regexes per base directory. + * + * @var array> + */ + private static array $compiledExcludesCache = []; + + /** + * Cached normalized cache directory path. + */ + private static ?string $cachedCacheDir = null; + + /** + * Cached TypePHP library src directory path. + */ + private static ?string $cachedLibSrcDir = null; + + /** + * Resets compiled pattern and directory caches. + */ + public static function reset(): void + { + self::$compiledIncludesCache = []; + self::$compiledExcludesCache = []; + self::$cachedCacheDir = null; + self::$cachedLibSrcDir = null; + } + + /** + * Normalizes directory separators to forward slashes. + */ + public static function normalizePath(string|false|null $path): string + { + if ($path === null || $path === false || $path === '') { + return ''; + } + + return str_replace('\\', '/', $path); + } + + /** + * Determines whether a given path is located within a vendor directory. + */ + public static function isVendorPath(string $normalizedPath, string $rawPath = ''): bool + { + $normalizedRaw = self::normalizePath($rawPath); + + return str_starts_with($normalizedPath, 'vendor/') + || str_contains($normalizedPath, '/vendor/') + || ($normalizedRaw !== '' && (str_starts_with($normalizedRaw, 'vendor/') || str_contains($normalizedRaw, '/vendor/'))); + } + + /** + * Determines whether a given path is within the TypePHP cache directory. + */ + public static function isCachePath(string $normalizedPath): bool + { + if (self::$cachedCacheDir === null) { + self::$cachedCacheDir = rtrim(self::normalizePath(CacheManager::getCacheDir()), '/') . '/'; + } + + return str_starts_with($normalizedPath, self::$cachedCacheDir); + } + + /** + * Determines whether a path belongs to TypePHP's own internal engine source files. + * In vendor mode: skips the entire library package. + * In development mode: skips only actual internal subdirectories, allowing test fixtures to be tested. + */ + public static function isLibraryInternal(string $normalizedPath): bool + { + if (self::$cachedLibSrcDir === null) { + $parentDir = realpath(__DIR__ . '/..'); + self::$cachedLibSrcDir = $parentDir !== false ? rtrim(self::normalizePath($parentDir), '/') . '/' : ''; + } + + $libSrcDir = self::$cachedLibSrcDir; + if ($libSrcDir === '') { + return false; + } + + if (str_contains($libSrcDir, '/vendor/')) { + return str_starts_with($normalizedPath, $libSrcDir); + } + + if (str_starts_with($normalizedPath, $libSrcDir)) { + $internalDirs = [ + $libSrcDir . 'Internal/', + $libSrcDir . 'Contract/', + $libSrcDir . 'Command/', + $libSrcDir . 'Validator/', + $libSrcDir . 'Wrapper/', + $libSrcDir . 'Resolver/', + $libSrcDir . 'Extension/', + $libSrcDir . 'Exception/', + $libSrcDir . 'TypePHP.php', + $libSrcDir . 'bootstrap.php', + ]; + + foreach ($internalDirs as $dir) { + if (str_starts_with($normalizedPath, $dir)) { + return true; + } + } + } + + return false; + } + + /** + * Converts a glob pattern into an absolute anchored regex pattern. + */ + public static function compileGlobToRegex(string $glob, string $baseDir): string + { + $glob = self::normalizePath(trim($glob)); + $isAbsolute = str_starts_with($glob, '/') || (bool) preg_match('#^[a-zA-Z]:/#', $glob); + + $regex = preg_quote($glob, '#'); + $regex = str_replace(['\*\*', '\*'], ['.*', '[^/]*'], $regex); + + if ($isAbsolute) { + $pattern = '^' . $regex . '$'; + } elseif ($glob === '*' || $glob === '**' || str_starts_with($glob, '**')) { + $pattern = '.*' . ($glob === '*' || $glob === '**' ? '' : substr($regex, 4)) . '$'; + } else { + $pattern = '(^' . preg_quote($baseDir . '/', '#') . '|^)' . $regex . '$'; + } + + return '#' . $pattern . '#i'; + } + + /** + * Evaluates path specificity against include and exclude globs. + * + * @param array $includeGlobs + * @param array $excludeGlobs + */ + public static function isPathIncluded( + string $normalizedPath, + array $includeGlobs, + array $excludeGlobs, + string $rawPath = '', + ?string $baseDir = null + ): bool { + $baseDir = $baseDir !== null ? self::normalizePath($baseDir) : Config::getProjectRoot(); + $normalizedRaw = self::normalizePath($rawPath); + + $includes = self::getCompiledPatterns($includeGlobs, $baseDir, 'include'); + $excludes = self::getCompiledPatterns($excludeGlobs, $baseDir, 'exclude'); + + $isVendor = self::isVendorPath($normalizedPath, $normalizedRaw); + if ($isVendor) { + $hasExplicitVendorWhitelist = false; + foreach ($includes as $compiled) { + if (str_starts_with($compiled['pattern'], 'vendor/') && + (preg_match($compiled['regex'], $normalizedPath) === 1 || ($normalizedRaw !== '' && preg_match($compiled['regex'], $normalizedRaw) === 1)) + ) { + $hasExplicitVendorWhitelist = true; + + break; + } + } + + if (! $hasExplicitVendorWhitelist) { + return false; + } + } + + $longestIncludeMatch = 0; + foreach ($includes as $compiled) { + $isExplicitVendorInclude = str_starts_with($compiled['pattern'], 'vendor/'); + $isWildcard = ($compiled['pattern'] === '*' || $compiled['pattern'] === '**'); + + if ($isVendor && ! $isExplicitVendorInclude && ! $isWildcard) { + continue; + } + + if (preg_match($compiled['regex'], $normalizedPath) === 1 || ($normalizedRaw !== '' && preg_match($compiled['regex'], $normalizedRaw) === 1)) { + $longestIncludeMatch = max($longestIncludeMatch, $compiled['len']); + } + } + + if ($longestIncludeMatch === 0) { + return false; + } + + $longestExcludeMatch = 0; + foreach ($excludes as $compiled) { + if (preg_match($compiled['regex'], $normalizedPath) === 1 || ($normalizedRaw !== '' && preg_match($compiled['regex'], $normalizedRaw) === 1)) { + $longestExcludeMatch = max($longestExcludeMatch, $compiled['len']); + } + } + + return $longestIncludeMatch > $longestExcludeMatch; + } + + /** + * @param array $globs + * + * @return array + */ + private static function getCompiledPatterns(array $globs, string $baseDir, string $type): array + { + $cacheKey = $baseDir . '|' . implode(',', $globs); + + if ($type === 'include' && isset(self::$compiledIncludesCache[$cacheKey])) { + return self::$compiledIncludesCache[$cacheKey]; + } + + if ($type === 'exclude' && isset(self::$compiledExcludesCache[$cacheKey])) { + return self::$compiledExcludesCache[$cacheKey]; + } + + $compiled = []; + foreach ($globs as $pattern) { + if (\is_string($pattern)) { + $trimmed = trim($pattern); + $compiled[] = [ + 'pattern' => $trimmed, + 'len' => \strlen($trimmed), + 'regex' => self::compileGlobToRegex($trimmed, $baseDir), + ]; + } + } + + if ($type === 'include') { + return self::$compiledIncludesCache[$cacheKey] = $compiled; + } + + return self::$compiledExcludesCache[$cacheKey] = $compiled; + } +} diff --git a/src/Internal/StreamWrapper.php b/src/Internal/StreamWrapper.php index 108dab1..512fe96 100644 --- a/src/Internal/StreamWrapper.php +++ b/src/Internal/StreamWrapper.php @@ -4,6 +4,8 @@ namespace TypePHP\Internal; +require_once __DIR__ . '/PathMatcher.php'; + use PhpParser\NodeTraverser; use PhpParser\NodeVisitor\CloningVisitor; use PhpParser\ParserFactory; @@ -33,20 +35,6 @@ final class StreamWrapper implements StreamWrapperInterface */ private $dirHandle = null; - /** - * @var array - */ - private static array $includeRawPatterns = []; - - /** - * @var array - */ - private static array $excludeRawPatterns = []; - - private static string $baseDir = ''; - - private static bool $isInitialized = false; - private static bool $isRegistered = false; private static bool $cacheEnabled = true; @@ -58,10 +46,7 @@ final class StreamWrapper implements StreamWrapperInterface */ public static function reset(): void { - self::$isInitialized = false; - self::$includeRawPatterns = []; - self::$excludeRawPatterns = []; - self::$baseDir = ''; + PathMatcher::reset(); } /** @@ -71,30 +56,8 @@ public static function register(array $config = []): void { $resolvedConfig = array_replace_recursive(Config::get(), $config); - if (! self::$isInitialized || \count($config) > 0) { - self::$baseDir = Config::getProjectRoot(); - - /** @var array $includes */ - $includes = \is_array($resolvedConfig['include'] ?? null) ? $resolvedConfig['include'] : ['**']; - - /** @var array $excludes */ - $excludes = \is_array($resolvedConfig['exclude'] ?? null) ? $resolvedConfig['exclude'] : ['vendor/**', 'storage/**', 'var/**', 'cache/**']; - - self::$includeRawPatterns = []; - foreach ($includes as $pattern) { - self::$includeRawPatterns[trim($pattern)] = self::compileGlobToRegex($pattern); - } - - self::$excludeRawPatterns = []; - foreach ($excludes as $pattern) { - self::$excludeRawPatterns[trim($pattern)] = self::compileGlobToRegex($pattern); - } - - self::$cacheEnabled = (bool) ($resolvedConfig['cache'] ?? true); - self::$cacheDir = CacheManager::getCacheDir(); - - self::$isInitialized = true; - } + self::$cacheEnabled = (bool) ($resolvedConfig['cache'] ?? true); + self::$cacheDir = CacheManager::getCacheDir(); if (! self::$isRegistered) { stream_wrapper_unregister('file'); @@ -449,28 +412,6 @@ public function rename(string $pathFrom, string $pathTo): bool return $result; } - /** - * Converts a glob pattern into an absolute regex pattern for path matching. - */ - private static function compileGlobToRegex(string $glob): string - { - $glob = str_replace('\\', '/', trim($glob)); - $isAbsolute = str_starts_with($glob, '/') || (bool) preg_match('#^[a-zA-Z]:/#', $glob); - - $regex = preg_quote($glob, '#'); - $regex = str_replace(['\*\*', '\*'], ['.*', '[^/]*'], $regex); - - if ($isAbsolute) { - $pattern = '^' . $regex . '$'; - } elseif ($glob === '*' || $glob === '**' || str_starts_with($glob, '**')) { - $pattern = '.*' . ($glob === '*' || $glob === '**' ? '' : substr($regex, 4)) . '$'; - } else { - $pattern = '(^' . preg_quote(self::$baseDir . '/', '#') . '|^.*\/)' . $regex . '$'; - } - - return '#' . $pattern . '#i'; - } - /** * Executes a callback while temporarily suppressing PHP error and warning handlers. * @@ -500,7 +441,7 @@ private static function isReadOnlyCall(): bool $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3); $callerFunc = strtolower($trace[2]['function'] ?? ''); - return \in_array($callerFunc, ['file_get_contents', 'file', 'readfile', 'highlight_file', 'show_source', 'token_get_all'], true); + return \in_array($callerFunc, ['file_get_contents', 'file', 'readfile', 'highlight_file', 'show_source', 'token_get_all'], strict: true); } /** @@ -509,71 +450,30 @@ private static function isReadOnlyCall(): bool private static function isApplicationFile(string $path, string|false $resolvedPath): bool { if (! Config::isEnabled()) { - return false; // TypePHP is globally disabled! + return false; } if (! str_ends_with($path, '.php') || $resolvedPath === false) { return false; } - $normalizedPath = str_replace('\\', '/', $resolvedPath); - - // Prevent parsing TypePHP's own source code - $parentDir = realpath(__DIR__ . '/..'); - $libSrcDir = $parentDir !== false ? str_replace('\\', '/', $parentDir) : ''; + $normalizedPath = PathMatcher::normalizePath($resolvedPath); - if ($libSrcDir !== '' && str_starts_with($normalizedPath, $libSrcDir)) { + if (PathMatcher::isLibraryInternal($normalizedPath)) { return false; } - $normalizedCacheDir = rtrim(str_replace('\\', '/', self::$cacheDir), '/') . '/'; - if (str_starts_with($normalizedPath, $normalizedCacheDir)) { + if (PathMatcher::isCachePath($normalizedPath)) { return false; } - $isVendorPath = str_contains($normalizedPath, '/vendor/'); - if ($isVendorPath) { - $hasExplicitVendorWhitelist = false; - foreach (self::$includeRawPatterns as $pattern => $regex) { - if (str_starts_with($pattern, 'vendor/') && preg_match($regex, $normalizedPath) === 1) { - $hasExplicitVendorWhitelist = true; - - break; - } - } - - if (! $hasExplicitVendorWhitelist) { - return false; - } - } - - $longestIncludeMatch = 0; - foreach (self::$includeRawPatterns as $pattern => $regex) { - $isExplicitVendorInclude = str_starts_with($pattern, 'vendor/'); - $isWildcard = ($pattern === '*' || $pattern === '**'); - - if ($isVendorPath && ! $isExplicitVendorInclude && ! $isWildcard) { - continue; - } - - if (preg_match($regex, $normalizedPath) === 1) { - $longestIncludeMatch = max($longestIncludeMatch, \strlen($pattern)); - } - } - - if ($longestIncludeMatch === 0) { - return false; - } - - $longestExcludeMatch = 0; - foreach (self::$excludeRawPatterns as $pattern => $regex) { - if (preg_match($regex, $normalizedPath) === 1) { - $longestExcludeMatch = max($longestExcludeMatch, \strlen($pattern)); - } - } + $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/**']; - // Equal specificity tie-breaker: Exclude wins! - return $longestIncludeMatch > $longestExcludeMatch; + return PathMatcher::isPathIncluded($normalizedPath, $includes, $excludes, $path); } private function openMemoryStream(string $resolvedPath): bool @@ -602,7 +502,7 @@ private function openCachedStream(string $resolvedPath, string $mode): bool { $cacheDir = self::$cacheDir; if (! is_dir($cacheDir)) { - self::silent(fn () => mkdir($cacheDir, 0777, true)); + self::silent(fn () => mkdir($cacheDir, 0777, recursive: true)); } $cachedFile = CacheManager::getCachedFilePath($resolvedPath); diff --git a/src/Internal/Visitor/FunctionContractInjector.php b/src/Internal/Visitor/FunctionContractInjector.php index 1838042..6998440 100644 --- a/src/Internal/Visitor/FunctionContractInjector.php +++ b/src/Internal/Visitor/FunctionContractInjector.php @@ -34,7 +34,7 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node): } $methodName = $isClassMethod ? strtolower($node->name->toString()) : ''; - $isMagicLifecycle = $isClassMethod && \in_array($methodName, ['__construct', '__destruct', '__clone'], true); + $isMagicLifecycle = $isClassMethod && \in_array($methodName, ['__construct', '__destruct', '__clone'], strict: true); $hasParam = $isClassMethod || str_contains($docText, '@param') || str_contains($docText, '@phpstan-param') || str_contains($docText, '@psalm-param'); $hasReturn = ! $isMagicLifecycle && ($isClassMethod || str_contains($docText, '@return') || str_contains($docText, '@phpstan-return') || str_contains($docText, '@psalm-return')); @@ -162,7 +162,7 @@ private static function buildSetupScopeStmt(Node\Expr $thisArg): Node\Stmt\If_ ['stmts' => [$throwStmt]] ); - $ifStmt->setAttribute('typephp_injected', true); + $ifStmt->setAttribute('typephp_injected', value: true); return $ifStmt; } @@ -196,7 +196,7 @@ private static function buildParamWrappers(array $params, string $wrapperFunc, N ) ) ); - $expr->setAttribute('typephp_injected', true); + $expr->setAttribute('typephp_injected', value: true); $wrappers[] = $expr; } } @@ -253,10 +253,10 @@ public static function buildVoidReturnGuard(Node\Expr\FuncCall $checkCall): arra ), ['stmts' => [self::buildTypeErrorThrowStmt(new Node\Expr\Variable('__typephpRet'))]] ); - $ifStmt->setAttribute('typephp_injected', true); + $ifStmt->setAttribute('typephp_injected', value: true); $retStmt = new Node\Stmt\Return_(null); - $retStmt->setAttribute('typephp_injected', true); + $retStmt->setAttribute('typephp_injected', value: true); return [$ifStmt, $retStmt]; } @@ -389,7 +389,7 @@ public function enterNode(Node $n): int|Node|null return null; } - $n->setAttribute('typephp_wrapped', true); + $n->setAttribute('typephp_wrapped', value: true); return FunctionContractInjector::buildWrappedYieldNode($n, $this->thisArg); } @@ -399,7 +399,7 @@ public function enterNode(Node $n): int|Node|null return null; } - $n->setAttribute('typephp_wrapped', true); + $n->setAttribute('typephp_wrapped', value: true); $n->expr = new Node\Expr\FuncCall( new Node\Name('\TypePHP\Internal\RuntimeTypeChecker::wrapIterable'), @@ -469,7 +469,7 @@ public function enterNode(Node $n): int|array|null $newStmts = array_merge($newStmts, self::buildVoidReturnGuard($checkCall)); } else { $retStmt = new Node\Stmt\Return_(self::buildTernaryReturnExpr($checkCall)); - $retStmt->setAttribute('typephp_injected', true); + $retStmt->setAttribute('typephp_injected', value: true); $newStmts[] = $retStmt; } } diff --git a/src/Internal/Visitor/PropertyHookInjector.php b/src/Internal/Visitor/PropertyHookInjector.php index 925ed82..15cb940 100644 --- a/src/Internal/Visitor/PropertyHookInjector.php +++ b/src/Internal/Visitor/PropertyHookInjector.php @@ -71,7 +71,7 @@ private static function processSetHook(Node\PropertyHook $hook, string $property NodeBuilder::createTernaryThrowExpr($checkCall) ) ); - $paramCheckStmt->setAttribute('typephp_injected', true); + $paramCheckStmt->setAttribute('typephp_injected', value: true); array_unshift($hook->body, $paramCheckStmt); } elseif ($hook->body instanceof Node\Expr) { $hook->body = self::buildExpressionSetHookTernary($checkCall, $hook->body); diff --git a/src/Resolver/SpecialTypeResolver.php b/src/Resolver/SpecialTypeResolver.php index c9195f7..eab96f1 100644 --- a/src/Resolver/SpecialTypeResolver.php +++ b/src/Resolver/SpecialTypeResolver.php @@ -245,7 +245,7 @@ public static function resolveForFile(TypeNode $node, string $file): TypeNode if ($node instanceof IdentifierTypeNode) { $lower = strtolower($node->name); - if (\in_array($lower, ['self', 'static', 'parent', '$this'], true)) { + if (\in_array($lower, ['self', 'static', 'parent', '$this'], strict: true)) { return clone $node; } diff --git a/src/Resolver/TemplateManager.php b/src/Resolver/TemplateManager.php index f33754f..c2c6149 100644 --- a/src/Resolver/TemplateManager.php +++ b/src/Resolver/TemplateManager.php @@ -258,7 +258,7 @@ public static function bindTemplate(string $function, ?object $thisObj, string $ public static function bindInstanceFromNode(object $instance, GenericTypeNode $typeNode, string $context = '', bool $forceBind = false): ?ErrorMessage { $className = $typeNode->type->name; - if (\in_array(strtolower($className), ['self', 'static', '$this'], true)) { + if (\in_array(strtolower($className), ['self', 'static', '$this'], strict: true)) { $className = \get_class($instance); } @@ -461,7 +461,7 @@ private static function bindInheritedGenericTag( string $actualClassName ): void { $parentName = SpecialTypeResolver::resolveFqcn($genericTypeNode->type->name, $hierClass); - $isHierarchyMember = is_a($actualClassName, $parentName, true) || trait_exists($parentName); + $isHierarchyMember = is_a($actualClassName, $parentName, allow_string: true) || trait_exists($parentName); if (! ClassNameValidator::isValid($parentName) || ! $isHierarchyMember) { return; @@ -654,7 +654,7 @@ private static function checkExistingIntersectionVariance(IntersectionTypeNode $ private static function checkNestedGenericVariance(GenericTypeNode $existing, GenericTypeNode $expected): bool { - if (! is_a($existing->type->name, $expected->type->name, true)) { + if (! is_a($existing->type->name, $expected->type->name, allow_string: true)) { return false; } @@ -673,7 +673,7 @@ private static function checkNestedGenericVariance(GenericTypeNode $existing, Ge private static function isSubclass(string $sub, string $super): bool { if (ClassNameValidator::isValid($sub) && ClassNameValidator::isValid($super) && (class_exists($sub) || interface_exists($sub)) && (class_exists($super) || interface_exists($super))) { - return is_a($sub, $super, true); + return is_a($sub, $super, allow_string: true); } return false; @@ -695,7 +695,7 @@ public static function bindInstance(object $instance, string $typeString, string } if ($typeNode instanceof GenericTypeNode) { - self::bindInstanceFromNode($instance, $typeNode, '', true); + self::bindInstanceFromNode($instance, $typeNode, '', forceBind: true); } } catch (\Throwable $e) { // Silently ignore malformed docblock strings diff --git a/src/Validator/ConstValidator.php b/src/Validator/ConstValidator.php index b5c3506..0d17231 100644 --- a/src/Validator/ConstValidator.php +++ b/src/Validator/ConstValidator.php @@ -52,7 +52,7 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali if (str_contains($pattern, '*')) { $allowedValues = self::resolveWildcardConstantValues($className, $pattern); - if (! \in_array($value, $allowedValues, true)) { + if (! \in_array($value, $allowedValues, strict: true)) { $fqcnPattern = $className !== '' ? "$className::$pattern" : $pattern; return ErrorFactory::createError($context . " must be a valid constant matching $fqcnPattern, " . TypeFormatter::formatGivenValue($value) . ' given'); diff --git a/src/Validator/GenericValidator.php b/src/Validator/GenericValidator.php index 58d13fb..2aa6010 100644 --- a/src/Validator/GenericValidator.php +++ b/src/Validator/GenericValidator.php @@ -130,7 +130,7 @@ private function validateKeyOf(mixed $value, GenericTypeNode $node, string $cont self::$enumKeyCache[$enumClass] = array_map(fn ($case) => $case->name, $enumClass::cases()); } - if (! \in_array($value, self::$enumKeyCache[$enumClass], true)) { + if (! \in_array($value, self::$enumKeyCache[$enumClass], strict: true)) { return ErrorFactory::createError($context . " must be a key of enum $enumClass, " . TypeFormatter::formatGivenValue($value) . ' given'); } @@ -148,7 +148,7 @@ private function validateKeyOf(mixed $value, GenericTypeNode $node, string $cont } } - if (! \in_array($value, $validKeys, true)) { + if (! \in_array($value, $validKeys, strict: true)) { return ErrorFactory::createError($context . ' must be a key of the specified array shape, ' . TypeFormatter::formatGivenValue($value) . ' given'); } @@ -183,7 +183,7 @@ private function validateValueOf(mixed $value, GenericTypeNode $node, string $co $constValue = $this->resolveConstantValue($fqcn, $constName); if (\is_array($constValue)) { - if (! \in_array($value, $constValue, true)) { + if (! \in_array($value, $constValue, strict: true)) { return ErrorFactory::createError($context . " must be a value of $cacheKey, " . TypeFormatter::formatGivenValue($value) . ' given'); } @@ -197,7 +197,7 @@ private function validateValueOf(mixed $value, GenericTypeNode $node, string $co self::$enumValueCache[$enumClass] = array_map(fn ($case) => $case->value, $enumClass::cases()); } - if (! \in_array($value, self::$enumValueCache[$enumClass], true)) { + if (! \in_array($value, self::$enumValueCache[$enumClass], strict: true)) { return ErrorFactory::createError($context . " must be a value of enum $enumClass, " . TypeFormatter::formatGivenValue($value) . ' given'); } @@ -348,7 +348,7 @@ private function validateClassString(mixed $value, GenericTypeNode $node, string if ($targetClassNode instanceof IdentifierTypeNode) { $targetName = $targetClassNode->name; if (class_exists($targetName) || interface_exists($targetName) || trait_exists($targetName) || enum_exists($targetName)) { - if (! is_a($value, $targetName, true)) { + if (! is_a($value, $targetName, allow_string: true)) { return ErrorFactory::createError($context . ' must be a class-string of ' . $targetName . ", '$value' given"); } } @@ -375,7 +375,7 @@ private function validateList(mixed $value, GenericTypeNode $node, string $conte $valueTypeNode = $node->genericTypes[0] ?? null; if ($valueTypeNode !== null) { foreach ($value as $k => $v) { - if ($valueTypeNode instanceof GenericTypeNode && ! \in_array(strtolower($valueTypeNode->type->name), ['class-string', 'list', 'array', 'iterable'], true)) { + if ($valueTypeNode instanceof GenericTypeNode && ! \in_array(strtolower($valueTypeNode->type->name), ['class-string', 'list', 'array', 'iterable'], strict: true)) { $err = $this->validateObjectGeneric($v, $valueTypeNode, $context . '[' . $k . ']'); if ($err !== null) { return $err; @@ -415,7 +415,7 @@ private function validateArray(mixed $value, GenericTypeNode $node, string $cont if ($typesCount === 1) { $valTypeNode = $node->genericTypes[0]; foreach ($value as $k => $v) { - if ($valTypeNode instanceof GenericTypeNode && ! \in_array(strtolower($valTypeNode->type->name), ['class-string', 'list', 'array', 'iterable'], true)) { + if ($valTypeNode instanceof GenericTypeNode && ! \in_array(strtolower($valTypeNode->type->name), ['class-string', 'list', 'array', 'iterable'], strict: true)) { $err = $this->validateObjectGeneric($v, $valTypeNode, $context . '[' . $k . ']'); if ($err !== null) { return $err; @@ -436,7 +436,7 @@ private function validateArray(mixed $value, GenericTypeNode $node, string $cont return $err; } - if ($valTypeNode instanceof GenericTypeNode && ! \in_array(strtolower($valTypeNode->type->name), ['class-string', 'list', 'array', 'iterable'], true)) { + if ($valTypeNode instanceof GenericTypeNode && ! \in_array(strtolower($valTypeNode->type->name), ['class-string', 'list', 'array', 'iterable'], strict: true)) { $err = $this->validateObjectGeneric($v, $valTypeNode, $context . "['" . $k . "']"); if ($err !== null) { return $err; diff --git a/src/Wrapper/CallableWrapper.php b/src/Wrapper/CallableWrapper.php index d826846..9a2a80c 100644 --- a/src/Wrapper/CallableWrapper.php +++ b/src/Wrapper/CallableWrapper.php @@ -54,7 +54,7 @@ public static function wrap(string $function, string $paramName, mixed $callable if (\is_array($callable) && $typeNode !== null) { $innerCallableTypeNode = null; - if ($typeNode instanceof GenericTypeNode && \in_array(strtolower($typeNode->type->name), ['list', 'array', 'iterable'], true)) { + if ($typeNode instanceof GenericTypeNode && \in_array(strtolower($typeNode->type->name), ['list', 'array', 'iterable'], strict: true)) { $innerCallableTypeNode = $typeNode->genericTypes[1] ?? $typeNode->genericTypes[0] ?? null; } elseif ($typeNode instanceof ArrayTypeNode) { $innerCallableTypeNode = $typeNode->type; diff --git a/src/Wrapper/IterableWrapper.php b/src/Wrapper/IterableWrapper.php index 3f23541..e0db17d 100644 --- a/src/Wrapper/IterableWrapper.php +++ b/src/Wrapper/IterableWrapper.php @@ -63,7 +63,7 @@ public static function wrap(string $function, string $paramName, mixed $iterable } $standardIterables = ['iterable', 'traversable', 'iterator', 'generator']; - if (! \in_array($baseName, $standardIterables, true)) { + if (! \in_array($baseName, $standardIterables, strict: true)) { return $iterable; } diff --git a/src/bootstrap.php b/src/bootstrap.php index 6c1a27b..05a3141 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -10,7 +10,22 @@ $isDisabledEnv = getenv('TYPEPHP_DISABLE') !== false && filter_var(getenv('TYPEPHP_DISABLE'), FILTER_VALIDATE_BOOLEAN); $isDisabledConst = \defined('TYPEPHP_DISABLE') && TYPEPHP_DISABLE; - if (! $isDisabledEnv && ! $isDisabledConst) { + $argv = $_SERVER['argv'] ?? null; + $stringArgs = \is_array($argv) ? array_filter($argv, 'is_string') : []; + $allArgs = implode(' ', $stringArgs); + $script = (isset($_SERVER['SCRIPT_NAME']) && \is_string($_SERVER['SCRIPT_NAME'])) ? $_SERVER['SCRIPT_NAME'] : ''; + + $normalized = str_replace('\\', '/', strtolower($allArgs . ' ' . $script)); + + $isTooling = str_contains($normalized, 'phpstan') + || str_contains($normalized, 'psalm') + || str_contains($normalized, 'php-cs-fixer') + || str_contains($normalized, 'pint') + || str_contains($normalized, 'rector') + || str_contains($normalized, 'mago') + || str_contains($normalized, 'composer'); + + if (! $isDisabledEnv && ! $isDisabledConst && ! $isTooling) { TypePHP::boot(); } -} +} \ No newline at end of file diff --git a/tests/Contract/VendorIsolationPathTest.php b/tests/Contract/VendorIsolationPathTest.php index 7d5a64a..d80a37d 100644 --- a/tests/Contract/VendorIsolationPathTest.php +++ b/tests/Contract/VendorIsolationPathTest.php @@ -47,11 +47,114 @@ ; }); + test('excludes relative vendor paths starting with vendor/ (without leading slash)', function () { + Config::set([ + 'include' => [ + 'src/**', + 'app/**', + 'tests/**', + ], + 'exclude' => [ + 'vendor/**', + 'storage/**', + 'var/**', + 'cache/**', + ], + ]); + + StreamWrapper::register(); + + $relativeVendorFile = 'vendor/doctrine/dbal/src/Schema/AbstractNamedObject.php'; + $relativeAppFile = 'src/Core/Framework/Util.php'; + + expect(FileFilter::isFileExcluded($relativeVendorFile))->toBeTrue() + ->and(FileFilter::isFileExcluded($relativeAppFile))->toBeFalse() + ; + + $refMethod = new ReflectionMethod(StreamWrapper::class, 'isApplicationFile'); + expect($refMethod->invoke(null, $relativeVendorFile, $relativeVendorFile))->toBeFalse() + ->and($refMethod->invoke(null, $relativeAppFile, $relativeAppFile))->toBeTrue() + ; + }); + + test('excludes composer relative traversal paths (vendor/composer/../doctrine/dbal/src/...)', function () { + Config::set([ + 'include' => [ + 'src/**', + 'app/**', + 'tests/**', + ], + 'exclude' => [ + 'vendor/**', + ], + ]); + + StreamWrapper::register(); + + $composerTraversalFile = 'vendor/composer/../doctrine/dbal/src/Schema/Column.php'; + + expect(FileFilter::isFileExcluded($composerTraversalFile))->toBeTrue(); + + $refMethod = new ReflectionMethod(StreamWrapper::class, 'isApplicationFile'); + expect($refMethod->invoke(null, $composerTraversalFile, $composerTraversalFile))->toBeFalse(); + }); + + test('excludes Windows relative paths starting with vendor\\', function () { + Config::set([ + 'include' => [ + 'src/**', + ], + 'exclude' => [ + 'vendor/**', + ], + ]); + + StreamWrapper::register(); + + $windowsRelativeVendor = 'vendor\\doctrine\\dbal\\src\\Schema\\Column.php'; + $windowsRelativeApp = 'src\\Core\\Framework\\Util.php'; + + expect(FileFilter::isFileExcluded($windowsRelativeVendor))->toBeTrue() + ->and(FileFilter::isFileExcluded($windowsRelativeApp))->toBeFalse() + ; + + $refMethod = new ReflectionMethod(StreamWrapper::class, 'isApplicationFile'); + expect($refMethod->invoke(null, $windowsRelativeVendor, $windowsRelativeVendor))->toBeFalse() + ->and($refMethod->invoke(null, $windowsRelativeApp, $windowsRelativeApp))->toBeTrue() + ; + }); + + test('src/** include pattern strictly matches project root src/ and does not match arbitrary nested src folders', function () { + Config::set([ + 'include' => [ + 'src/**', + ], + 'exclude' => [ + 'vendor/**', + ], + ]); + + StreamWrapper::register(); + + $projectRoot = Config::getProjectRoot(); + $rootSrcFile = str_replace('\\', '/', $projectRoot . '/src/Service.php'); + $nestedSrcFile = str_replace('\\', '/', $projectRoot . '/packages/custom-tool/src/Helper.php'); + + expect(FileFilter::isFileExcluded($rootSrcFile))->toBeFalse() + ->and(FileFilter::isFileExcluded($nestedSrcFile))->toBeTrue() + ; + + $refMethod = new ReflectionMethod(StreamWrapper::class, 'isApplicationFile'); + expect($refMethod->invoke(null, $rootSrcFile, $rootSrcFile))->toBeTrue() + ->and($refMethod->invoke(null, $nestedSrcFile, $nestedSrcFile))->toBeFalse() + ; + }); + test('allows explicitly whitelisted vendor packages while strictly excluding all other vendor files', function () { Config::set([ 'include' => [ 'src/**', - 'vendor/my-org/whitelisted-package/**', + 'vendor/my-org/whitelisted-package/**', ], 'exclude' => [ 'vendor/**', @@ -78,7 +181,7 @@ Config::set([ 'include' => [ 'src/**', - 'src/Core/Framework/**', + 'src/Core/Framework/**', 'src/Core/Content/**', ], 'exclude' => [ @@ -256,4 +359,4 @@ ->and($refMethod->invoke(null, $windowsAppPath, $windowsAppPath))->toBeTrue() ; }); -}); \ No newline at end of file +}); diff --git a/tests/Internal/PathMatcherTest.php b/tests/Internal/PathMatcherTest.php new file mode 100644 index 0000000..14f04e6 --- /dev/null +++ b/tests/Internal/PathMatcherTest.php @@ -0,0 +1,197 @@ +toBe('') + ->and(PathMatcher::normalizePath(false))->toBe('') + ->and(PathMatcher::normalizePath(''))->toBe('') + ; + }); + + test('converts Windows backslashes to forward slashes', function () { + expect(PathMatcher::normalizePath('C:\\project\\src\\Service.php')) + ->toBe('C:/project/src/Service.php') + ->and(PathMatcher::normalizePath('vendor\\composer\\autoload.php')) + ->toBe('vendor/composer/autoload.php') + ->and(PathMatcher::normalizePath('mixed/path\\to/file.php')) + ->toBe('mixed/path/to/file.php') + ; + }); + }); + + describe('isVendorPath()', function () { + test('identifies absolute and relative vendor paths correctly', function () { + expect(PathMatcher::isVendorPath('vendor/doctrine/dbal/src/Schema.php'))->toBeTrue() + ->and(PathMatcher::isVendorPath('/var/www/project/vendor/monolog/monolog/src/Logger.php'))->toBeTrue() + ->and(PathMatcher::isVendorPath('C:/project/vendor/symfony/console/Application.php'))->toBeTrue() + ; + }); + + test('identifies vendor paths when raw path has Windows backslashes', function () { + expect(PathMatcher::isVendorPath('vendor/foo/bar.php', 'vendor\\foo\\bar.php'))->toBeTrue() + ->and(PathMatcher::isVendorPath('C:/project/vendor/foo.php', 'C:\\project\\vendor\\foo.php'))->toBeTrue() + ; + }); + + test('does not falsely classify application directories with vendor prefix as vendor directory', function () { + expect(PathMatcher::isVendorPath('vendor-tools/Deploy.php'))->toBeFalse() + ->and(PathMatcher::isVendorPath('vendor_custom/Helper.php'))->toBeFalse() + ->and(PathMatcher::isVendorPath('/var/www/vendor-tools/Script.php'))->toBeFalse() + ->and(PathMatcher::isVendorPath('src/Services/UserService.php'))->toBeFalse() + ; + }); + }); + + describe('isCachePath()', function () { + test('identifies paths inside TypePHP cache directory', function () { + $cacheDir = PathMatcher::normalizePath(CacheManager::getCacheDir()); + $cachedFile = $cacheDir . '/v0.1_hash123.php'; + $normalFile = '/var/www/project/src/App.php'; + + expect(PathMatcher::isCachePath($cachedFile))->toBeTrue() + ->and(PathMatcher::isCachePath($normalFile))->toBeFalse() + ; + }); + }); + + describe('isLibraryInternal()', function () { + test('identifies TypePHP internal engine directories correctly', function () { + $projectRoot = PathMatcher::normalizePath(Config::getProjectRoot()); + + $internalFile = $projectRoot . '/src/Internal/RuntimeTypeChecker.php'; + $contractFile = $projectRoot . '/src/Contract/FileFilter.php'; + $bootstrapFile = $projectRoot . '/src/bootstrap.php'; + $mockAppFile = $projectRoot . '/src/Service.php'; + + expect(PathMatcher::isLibraryInternal($internalFile))->toBeTrue() + ->and(PathMatcher::isLibraryInternal($contractFile))->toBeTrue() + ->and(PathMatcher::isLibraryInternal($bootstrapFile))->toBeTrue() + ->and(PathMatcher::isLibraryInternal($mockAppFile))->toBeFalse() + ; + }); + }); + + describe('compileGlobToRegex()', function () { + test('compiles absolute glob patterns into exact anchored regex', function () { + $baseDir = '/var/www/project'; + $regex = PathMatcher::compileGlobToRegex('/var/www/project/src/**', $baseDir); + + expect(preg_match($regex, '/var/www/project/src/Service.php'))->toBe(1) + ->and(preg_match($regex, '/var/www/other/src/Service.php'))->toBe(0) + ; + }); + + test('compiles wildcard * and ** globs', function () { + $baseDir = '/var/www/project'; + + $wildcardAll = PathMatcher::compileGlobToRegex('**', $baseDir); + expect(preg_match($wildcardAll, '/var/www/project/any/deep/file.php'))->toBe(1); + + $singleStar = PathMatcher::compileGlobToRegex('*', $baseDir); + expect(preg_match($singleStar, '/var/www/project/index.php'))->toBe(1); + }); + + test('compiles relative globs strictly anchored to project root or relative start', function () { + $baseDir = '/var/www/project'; + $regex = PathMatcher::compileGlobToRegex('src/**', $baseDir); + + expect(preg_match($regex, '/var/www/project/src/Core/Helper.php'))->toBe(1) + ->and(preg_match($regex, 'src/Core/Helper.php'))->toBe(1) + ->and(preg_match($regex, '/var/www/project/vendor/doctrine/dbal/src/Column.php'))->toBe(0) + ->and(preg_match($regex, '/var/www/project/packages/tool/src/Helper.php'))->toBe(0) + ; + }); + }); + + describe('isPathIncluded() with Specificity Rules', function () { + test('matches application files when included and not excluded', function () { + $projectRoot = PathMatcher::normalizePath(Config::getProjectRoot()); + $includes = ['src/**', 'app/**']; + $excludes = ['vendor/**', 'storage/**']; + + $appFile = $projectRoot . '/app/Services/UserService.php'; + expect(PathMatcher::isPathIncluded($appFile, $includes, $excludes))->toBeTrue(); + }); + + test('excludes vendor files by default even with nested src folders', function () { + $projectRoot = PathMatcher::normalizePath(Config::getProjectRoot()); + $includes = ['src/**', 'app/**', 'tests/**']; + $excludes = ['vendor/**', 'storage/**']; + + $vendorFile = $projectRoot . '/vendor/doctrine/dbal/src/Schema/Column.php'; + expect(PathMatcher::isPathIncluded($vendorFile, $includes, $excludes))->toBeFalse(); + }); + + test('allows whitelisted vendor packages with explicit vendor/ include pattern', function () { + $projectRoot = PathMatcher::normalizePath(Config::getProjectRoot()); + $includes = ['src/**', 'vendor/my-org/whitelisted-package/**']; + $excludes = ['vendor/**']; + + $whitelistedFile = $projectRoot . '/vendor/my-org/whitelisted-package/src/Service.php'; + $unwhitelistedFile = $projectRoot . '/vendor/doctrine/dbal/src/Schema/Column.php'; + + expect(PathMatcher::isPathIncluded($whitelistedFile, $includes, $excludes))->toBeTrue() + ->and(PathMatcher::isPathIncluded($unwhitelistedFile, $includes, $excludes))->toBeFalse() + ; + }); + + test('allows blacklisting a specific single file inside an included directory', function () { + $projectRoot = PathMatcher::normalizePath(Config::getProjectRoot()); + $includes = ['src/**']; + $excludes = ['src/Legacy/UnsafeFile.php']; + + $safeFile = $projectRoot . '/src/SafeService.php'; + $unsafeFile = $projectRoot . '/src/Legacy/UnsafeFile.php'; + + expect(PathMatcher::isPathIncluded($safeFile, $includes, $excludes))->toBeTrue() + ->and(PathMatcher::isPathIncluded($unsafeFile, $includes, $excludes))->toBeFalse() + ; + }); + + test('excludes wins tie-breaker when include and exclude have equal pattern length', function () { + $projectRoot = PathMatcher::normalizePath(Config::getProjectRoot()); + $includes = ['src/Config.php']; + $excludes = ['src/Config.php']; + + $file = $projectRoot . '/src/Config.php'; + expect(PathMatcher::isPathIncluded($file, $includes, $excludes))->toBeFalse(); + }); + + test('handles relative paths without leading slash cleanly', function () { + $includes = ['src/**', 'app/**']; + $excludes = ['vendor/**']; + + expect(PathMatcher::isPathIncluded('src/Core/Util.php', $includes, $excludes, 'src/Core/Util.php'))->toBeTrue() + ->and(PathMatcher::isPathIncluded('vendor/doctrine/dbal/src/Column.php', $includes, $excludes, 'vendor/doctrine/dbal/src/Column.php'))->toBeFalse() + ; + }); + }); + + describe('reset()', function () { + test('clears internal pattern and directory caches cleanly', function () { + PathMatcher::normalizePath('test/path'); + PathMatcher::isCachePath('some/path'); + + PathMatcher::reset(); + + expect(true)->toBeTrue(); + }); + }); +}); diff --git a/tests/RuntimeChecker/ParamCheckerTest.php b/tests/RuntimeChecker/ParamCheckerTest.php index ef2b761..996e088 100644 --- a/tests/RuntimeChecker/ParamCheckerTest.php +++ b/tests/RuntimeChecker/ParamCheckerTest.php @@ -240,7 +240,8 @@ 'userRole' => 'admin', ], $service, $registry); expect($invalidErr)->toBeInstanceOf(ErrorMessage::class) - ->and($invalidErr->getMessage())->toContain('positive-int'); + ->and($invalidErr->getMessage())->toContain('positive-int') + ; }); }); }); diff --git a/tests/RuntimeChecker/ReturnCheckerTest.php b/tests/RuntimeChecker/ReturnCheckerTest.php index f8c92a5..2874597 100644 --- a/tests/RuntimeChecker/ReturnCheckerTest.php +++ b/tests/RuntimeChecker/ReturnCheckerTest.php @@ -226,7 +226,8 @@ function () use (&$wrappedCalled) { ); expect($result)->toBe($files) - ->and($wrappedCalled)->toBeFalse(); + ->and($wrappedCalled)->toBeFalse() + ; }); }); });