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()
+ ;
});
});
});