From 7ab777d12fedd57827a798d60770ad59f241ce87 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 11 Sep 2026 21:25:26 +0800 Subject: [PATCH 1/4] Implement vendor boundary enforcement and type checking improvements --- src/Internal/Cli/ConfigInitCommand.php | 15 +- .../Resolver/CallerBoundaryResolver.php | 167 +++++++++ src/Internal/RuntimeTypeChecker.php | 20 +- src/Internal/Util/Config.php | 19 +- src/Internal/Util/PathMatcher.php | 10 +- src/Internal/Wrapper/CallableWrapper.php | 34 +- src/Internal/Wrapper/IterableWrapper.php | 26 +- .../VendorBoundaryIsolationTest.php | 327 ++++++++++++++++++ 8 files changed, 593 insertions(+), 25 deletions(-) create mode 100644 src/Internal/Resolver/CallerBoundaryResolver.php create mode 100644 tests/TypeChecking/Boundaries/VendorBoundaryIsolationTest.php diff --git a/src/Internal/Cli/ConfigInitCommand.php b/src/Internal/Cli/ConfigInitCommand.php index 4249999b..af9a595c 100644 --- a/src/Internal/Cli/ConfigInitCommand.php +++ b/src/Internal/Cli/ConfigInitCommand.php @@ -78,6 +78,17 @@ private static function getTemplate(): string */ 'strict_return_generic_invariance' => true, + /* + |-------------------------------------------------------------------------- + | Vendor Boundary Only Enforcement + |-------------------------------------------------------------------------- + | When true (default), whitelisted vendor packages (e.g. Illuminate\Collections) + | only enforce type contracts on calls originating from application code (included paths). + | Internal vendor-to-vendor or vendor-self calls bypass strict enforcement. + | Set to false for strict pedantic enforcement across all vendor internals. + */ + 'vendor_boundary_only' => true, + /* |-------------------------------------------------------------------------- | Magic Annotations (@property & @method) @@ -110,7 +121,7 @@ private static function getTemplate(): string | is caught without exception. | | - 'hybrid' : (Beartype O(1) Mode) Fast boundary + random sampling on - | arrays > 64 items. Ideal for massive production datasets. + | arrays > 128 items. Ideal for massive production datasets. */ 'array_validation' => 'full', @@ -224,4 +235,4 @@ private static function getTemplate(): string ]; PHP; } -} +} \ No newline at end of file diff --git a/src/Internal/Resolver/CallerBoundaryResolver.php b/src/Internal/Resolver/CallerBoundaryResolver.php new file mode 100644 index 00000000..dfe9bd46 --- /dev/null +++ b/src/Internal/Resolver/CallerBoundaryResolver.php @@ -0,0 +1,167 @@ + functionName => isCalleeVendor + */ + private static array $calleeVendorCache = []; + + /** + * @var array normalizedFilePath => isCallerVendor + */ + private static array $callerVendorCache = []; + + public static function reset(): void + { + self::$calleeVendorCache = []; + self::$callerVendorCache = []; + } + + /** + * Determines whether a type check failure on a function/method should be bypassed. + * Only bypasses if the callee is located in a vendor path AND the caller is also from vendor. + */ + public static function shouldBypass(string $function): bool + { + if (! Config::isVendorBoundaryOnlyEnabled()) { + return false; + } + + // 1. If the callee itself is an application class/function, ALWAYS enforce! + if (! self::isCalleeVendor($function)) { + return false; + } + + // 2. If the callee is in vendor, check who initiated the call + return self::isCallerVendor(); + } + + /** + * Determines whether a type check failure inside a wrapped callback/closure should be bypassed. + */ + public static function shouldBypassCallback(mixed $callable, string $prefix = ''): bool + { + if (! Config::isVendorBoundaryOnlyEnabled()) { + return false; + } + + // If the closure was defined inside a vendor file, bypass! + if ($callable instanceof Closure) { + try { + $ref = new ReflectionFunction($callable); + $file = $ref->getFileName(); + if ($file !== false && $file !== null) { + $normalized = PathMatcher::normalizePath($file); + + return PathMatcher::isVendorPath($normalized); + } + } catch (Throwable $e) { + } + } + + return self::isCallerVendor(); + } + + /** + * Checks if the function or method being executed belongs to a vendor file. + */ + public static function isCalleeVendor(string $function): bool + { + if (isset(self::$calleeVendorCache[$function])) { + return self::$calleeVendorCache[$function]; + } + + $fileName = null; + + try { + if (str_contains($function, '::')) { + [$class] = explode('::', $function, 2); + if (class_exists($class) || interface_exists($class) || trait_exists($class)) { + /** @var class-string $class */ + $ref = new ReflectionClass($class); + $fileName = $ref->getFileName(); + } + } elseif (\function_exists($function)) { + $ref = new ReflectionFunction($function); + $fileName = $ref->getFileName(); + } + } catch (Throwable $e) { + } + + if ($fileName === null || $fileName === false) { + return self::$calleeVendorCache[$function] = false; + } + + $normalized = PathMatcher::normalizePath($fileName); + + return self::$calleeVendorCache[$function] = PathMatcher::isVendorPath($normalized); + } + + /** + * Inspects the call stack to see if the caller originated from vendor code. + */ + public static function isCallerVendor(): bool + { + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 10); + + for ($i = 1; $i < \count($trace); $i++) { + $frame = $trace[$i]; + $class = $frame['class'] ?? ''; + $function = $frame['function'] ?? ''; + $file = $frame['file'] ?? ''; + + // Skip internal TypePHP engine frames + if ($class !== '' && (str_starts_with($class, 'TypePHP\\Internal\\') || $class === 'TypePHP\\TypePHP')) { + continue; + } + + // Skip standard procedural call_user_func wrappers + if ($class === '' && \in_array($function, ['call_user_func', 'call_user_func_array'], true)) { + continue; + } + + if ($file === '') { + continue; + } + + $normalizedFile = PathMatcher::normalizePath($file); + + if (PathMatcher::isLibraryInternal($normalizedFile)) { + continue; + } + + // Skip test runner framework internals (PHPUnit / Pest constraint wrappers) + if ( + str_contains($normalizedFile, '/vendor/phpunit/') + || str_contains($normalizedFile, '/vendor/pestphp/') + ) { + continue; + } + + if (isset(self::$callerVendorCache[$normalizedFile])) { + return self::$callerVendorCache[$normalizedFile]; + } + + $isVendor = PathMatcher::isVendorPath($normalizedFile); + + return self::$callerVendorCache[$normalizedFile] = $isVendor; + } + + return false; + } +} \ No newline at end of file diff --git a/src/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index ddef5405..3cf9a3a2 100644 --- a/src/Internal/RuntimeTypeChecker.php +++ b/src/Internal/RuntimeTypeChecker.php @@ -14,6 +14,7 @@ use TypePHP\Internal\Diagnostic\ErrorMessage; use TypePHP\Internal\Docblock\DocblockParser; use TypePHP\Internal\Generics\TemplateManager; +use TypePHP\Internal\Resolver\CallerBoundaryResolver; use TypePHP\Internal\Util\Config; use TypePHP\Internal\Util\IgnoreManager; use TypePHP\Internal\Validator\TypeValidatorRegistry; @@ -42,6 +43,7 @@ public static function reset(): void { self::$hasMethodTemplatesCache = []; IgnoreManager::reset(); + CallerBoundaryResolver::reset(); } /** @@ -63,7 +65,7 @@ public static function bindInstanceFromNode(object $instance, GenericTypeNode $t $err = TemplateManager::bindInstanceFromNode($instance, $typeNode, $context, $forceBind); - if ($err !== null && IgnoreManager::isCallerIgnored()) { + if ($err !== null && (IgnoreManager::isCallerIgnored() || CallerBoundaryResolver::shouldBypass($context))) { return null; } @@ -95,7 +97,7 @@ public static function checkVariable( $thisOrClass ); - if ($res instanceof ErrorMessage && IgnoreManager::isCallerIgnored()) { + if ($res instanceof ErrorMessage && (IgnoreManager::isCallerIgnored() || CallerBoundaryResolver::shouldBypass($caller ?? ''))) { return $value; } @@ -118,7 +120,7 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string $res = InlineChecker::checkProperty($value, $objectOrClass, $propName, $file, self::getRegistry()); - if ($res instanceof ErrorMessage && IgnoreManager::isCallerIgnored()) { + if ($res instanceof ErrorMessage && (IgnoreManager::isCallerIgnored() || CallerBoundaryResolver::shouldBypass($className . '::$' . $propName))) { return $value; } @@ -170,7 +172,7 @@ public static function setupScope(string $function, array $vars, object|string|n ); if ($err !== null) { - if (IgnoreManager::isCallerIgnored()) { + if (IgnoreManager::isCallerIgnored() || CallerBoundaryResolver::shouldBypass($effectiveFunction)) { return null; } @@ -203,7 +205,7 @@ public static function checkParams(string $function, array $vars, object|string| $err = ParamChecker::checkParams($function, $vars, $thisOrClass, self::getRegistry()); - if ($err !== null && IgnoreManager::isCallerIgnored()) { + if ($err !== null && (IgnoreManager::isCallerIgnored() || CallerBoundaryResolver::shouldBypass($function))) { return null; } @@ -262,7 +264,7 @@ public static function checkReturn(string $function, mixed $value, object|string $contract ); - if ($res instanceof ErrorMessage && IgnoreManager::isCallerIgnored()) { + if ($res instanceof ErrorMessage && (IgnoreManager::isCallerIgnored() || CallerBoundaryResolver::shouldBypass($effectiveFunction))) { return $value; } @@ -280,7 +282,7 @@ public static function checkSend(string $function, mixed $sendValue, object|stri $res = GeneratorChecker::checkSend($function, $sendValue, self::getRegistry(), $thisOrClass); - if ($res instanceof ErrorMessage && IgnoreManager::isCallerIgnored()) { + if ($res instanceof ErrorMessage && (IgnoreManager::isCallerIgnored() || CallerBoundaryResolver::shouldBypass($function))) { return $sendValue; } @@ -298,7 +300,7 @@ public static function checkYield(string $function, mixed $key, mixed $value, ob $res = GeneratorChecker::checkYield($function, $key, $value, self::getRegistry(), $thisOrClass); - if ($res instanceof ErrorMessage && IgnoreManager::isCallerIgnored()) { + if ($res instanceof ErrorMessage && (IgnoreManager::isCallerIgnored() || CallerBoundaryResolver::shouldBypass($function))) { return $value; } @@ -370,4 +372,4 @@ public static function getRegistry(): TypeValidatorRegistry { return self::$registry ??= new TypeValidatorRegistry(); } -} +} \ No newline at end of file diff --git a/src/Internal/Util/Config.php b/src/Internal/Util/Config.php index fc8dc023..77b17c4c 100644 --- a/src/Internal/Util/Config.php +++ b/src/Internal/Util/Config.php @@ -11,6 +11,7 @@ use TypePHP\Internal\Generics\TemplateManager; use TypePHP\Internal\Io\CacheManager; use TypePHP\Internal\Io\StreamWrapper; +use TypePHP\Internal\Resolver\CallerBoundaryResolver; use TypePHP\Internal\Resolver\HierarchyResolver; use TypePHP\Internal\Resolver\SpecialTypeResolver; use TypePHP\Internal\RuntimeTypeChecker; @@ -53,6 +54,8 @@ final class Config private static bool $respectNativeNullability = true; + private static bool $vendorBoundaryOnly = true; + private static string $arrayValidation = 'full'; public static function isEnabled(): bool @@ -127,6 +130,15 @@ public static function isRespectNativeNullabilityEnabled(): bool return self::$respectNativeNullability; } + public static function isVendorBoundaryOnlyEnabled(): bool + { + if (self::$cachedConfig === null) { + self::get(); + } + + return self::$vendorBoundaryOnly; + } + public static function isArrayValidationHybrid(): bool { if (self::$cachedConfig === null) { @@ -220,6 +232,7 @@ public static function get(): array 'magic_methods' => true, 'respect_ignore_tags' => true, 'respect_native_nullability' => true, + 'vendor_boundary_only' => true, 'array_validation' => 'full', 'cache' => true, 'cache_dir' => null, @@ -308,6 +321,7 @@ public static function set(array $config): void StreamWrapper::reset(); StubManager::reset(); SpecialTypeResolver::reset(); + CallerBoundaryResolver::reset(); CacheManager::reset(); IgnoreManager::reset(); RuntimeTypeChecker::reset(); @@ -360,6 +374,7 @@ public static function reset(): void self::$magicMethods = true; self::$respectIgnoreTags = true; self::$respectNativeNullability = true; + self::$vendorBoundaryOnly = true; self::$arrayValidation = 'full'; DocblockParser::reset(); @@ -372,6 +387,7 @@ public static function reset(): void StreamWrapper::reset(); StubManager::reset(); SpecialTypeResolver::reset(); + CallerBoundaryResolver::reset(); CacheManager::reset(); IgnoreManager::reset(); RuntimeTypeChecker::reset(); @@ -392,6 +408,7 @@ private static function syncFlags(array $config): void self::$magicMethods = (bool) ($config['magic_methods'] ?? true); self::$respectIgnoreTags = (bool) ($config['respect_ignore_tags'] ?? true); self::$respectNativeNullability = (bool) ($config['respect_native_nullability'] ?? true); + self::$vendorBoundaryOnly = (bool) ($config['vendor_boundary_only'] ?? true); self::$arrayValidation = \is_string($config['array_validation'] ?? null) ? $config['array_validation'] : 'full'; } -} +} \ No newline at end of file diff --git a/src/Internal/Util/PathMatcher.php b/src/Internal/Util/PathMatcher.php index b9bcf431..d420c8d9 100644 --- a/src/Internal/Util/PathMatcher.php +++ b/src/Internal/Util/PathMatcher.php @@ -131,7 +131,7 @@ public static function isVendorPath(string $normalizedPath, string $rawPath = '' } /** - * Determines whether a given path belongs to an immutable, static source code repositor + * Determines whether a given path belongs to an immutable, static source code repository. */ public static function isStaticSourcePath(string $normalizedPath): bool { @@ -368,8 +368,10 @@ public static function isPathIncluded( if ($isVendor) { $hasExplicitVendorWhitelist = false; foreach ($includes as $compiled) { + $isVendorPattern = self::isVendorPath($compiled['pattern']) || str_starts_with($compiled['pattern'], 'vendor/') || str_contains($compiled['pattern'], '/vendor/'); + if ( - str_starts_with($compiled['pattern'], 'vendor/') && + $isVendorPattern && (preg_match($compiled['regex'], $normalizedPath) === 1 || ($normalizedRaw !== '' && preg_match($compiled['regex'], $normalizedRaw) === 1)) ) { $hasExplicitVendorWhitelist = true; @@ -385,7 +387,7 @@ public static function isPathIncluded( $longestIncludeMatch = 0; foreach ($includes as $compiled) { - $isExplicitVendorInclude = str_starts_with($compiled['pattern'], 'vendor/'); + $isExplicitVendorInclude = self::isVendorPath($compiled['pattern']) || str_starts_with($compiled['pattern'], 'vendor/') || str_contains($compiled['pattern'], '/vendor/'); $isWildcard = ($compiled['pattern'] === '*' || $compiled['pattern'] === '**'); if ($isVendor && ! $isExplicitVendorInclude && ! $isWildcard) { @@ -446,4 +448,4 @@ private static function getCompiledPatterns(array $globs, string $baseDir, strin return self::$compiledExcludesCache[$cacheKey] = $compiled; } -} +} \ No newline at end of file diff --git a/src/Internal/Wrapper/CallableWrapper.php b/src/Internal/Wrapper/CallableWrapper.php index 56c6e530..5e91d131 100644 --- a/src/Internal/Wrapper/CallableWrapper.php +++ b/src/Internal/Wrapper/CallableWrapper.php @@ -18,6 +18,7 @@ use TypePHP\Internal\Docblock\DocblockParser; use TypePHP\Internal\Generics\TemplateManager; use TypePHP\Internal\Generics\TemplateSubstitutor; +use TypePHP\Internal\Resolver\CallerBoundaryResolver; use TypePHP\Internal\Resolver\SpecialTypeResolver; use TypePHP\Internal\Validator\TypeValidatorRegistry; @@ -134,7 +135,7 @@ public static function wrapTypeNode(?TypeNode $typeNode, mixed $callable, string /** @var callable $callable */ return function (...$args) use ($callable, $typeNode, $registry, $prefix) { - self::validateCallbackArguments($typeNode, $args, $prefix, $registry); + self::validateCallbackArguments($typeNode, $args, $prefix, $registry, $callable); try { $result = $callable(...$args); @@ -148,7 +149,9 @@ public static function wrapTypeNode(?TypeNode $typeNode, mixed $callable, string if (! $isVoidReturn) { $err = $registry->validate($result, $typeNode->returnType, "$prefix return value"); if ($err !== null) { - throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); + if (! CallerBoundaryResolver::shouldBypassCallback($callable, $prefix)) { + throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); + } } } @@ -166,12 +169,20 @@ public static function wrapTypeNode(?TypeNode $typeNode, mixed $callable, string private static function enforceClosureConstraints(string $identifierName, mixed $callable, string $prefix): void { if (str_contains($identifierName, 'closure') && ! ($callable instanceof Closure)) { + if (CallerBoundaryResolver::shouldBypassCallback($callable, $prefix)) { + return; + } + throw ErrorFactory::prepareException(new TypePHPTypeError($prefix . ' must be of type Closure, ' . TypeFormatter::formatGivenValue($callable) . ' given')); } if (str_contains($identifierName, 'static') && $callable instanceof Closure) { $refFunc = new ReflectionFunction($callable); if ($refFunc->getClosureThis() !== null) { + if (CallerBoundaryResolver::shouldBypassCallback($callable, $prefix)) { + return; + } + throw ErrorFactory::prepareException(new TypePHPTypeError($prefix . ' must be a static Closure (not bound to $this)')); } } @@ -182,8 +193,13 @@ private static function enforceClosureConstraints(string $identifierName, mixed * * @param array $args */ - private static function validateCallbackArguments(CallableTypeNode $typeNode, array $args, string $prefix, TypeValidatorRegistry $registry): void - { + private static function validateCallbackArguments( + CallableTypeNode $typeNode, + array $args, + string $prefix, + TypeValidatorRegistry $registry, + mixed $callable = null + ): void { $argValues = array_values($args); $argCount = \count($argValues); @@ -194,6 +210,10 @@ private static function validateCallbackArguments(CallableTypeNode $typeNode, ar for ($vIdx = $index; $vIdx < $argCount; $vIdx++) { $err = $registry->validate($argValues[$vIdx], $paramNode->type, "$prefix variadic argument #" . ($vIdx + 1)); if ($err !== null) { + if ($callable !== null && CallerBoundaryResolver::shouldBypassCallback($callable, $prefix)) { + continue; + } + throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); } } @@ -216,9 +236,13 @@ private static function validateCallbackArguments(CallableTypeNode $typeNode, ar $argLabel = $rawParamName !== '' ? "\$$rawParamName" : ('argument #' . ($index + 1)); $err = $registry->validate($val, $paramNode->type, "$prefix $argLabel"); if ($err !== null) { + if ($callable !== null && CallerBoundaryResolver::shouldBypassCallback($callable, $prefix)) { + continue; + } + throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); } } } } -} +} \ No newline at end of file diff --git a/src/Internal/Wrapper/IterableWrapper.php b/src/Internal/Wrapper/IterableWrapper.php index 016ef828..cc0b18a9 100644 --- a/src/Internal/Wrapper/IterableWrapper.php +++ b/src/Internal/Wrapper/IterableWrapper.php @@ -14,6 +14,7 @@ use TypePHP\Internal\Docblock\DocblockParser; use TypePHP\Internal\Generics\TemplateManager; use TypePHP\Internal\Generics\TemplateSubstitutor; +use TypePHP\Internal\Resolver\CallerBoundaryResolver; use TypePHP\Internal\Resolver\SpecialTypeResolver; use TypePHP\Internal\Validator\TypeValidatorRegistry; @@ -74,7 +75,7 @@ public static function wrap(string $function, string $paramName, mixed $iterable } $prefix = ($paramName === 'return') ? "$function(): Return iterator" : "$function(): Iterator \$$paramName"; - $typeCheckCallback = self::createValidationCallback($registry, $keyTypeNode, $itemTypeNode, $prefix); + $typeCheckCallback = self::createValidationCallback($registry, $keyTypeNode, $itemTypeNode, $prefix, $function); if (\is_array($iterable)) { return self::wrapGenerator((function () use ($iterable) { @@ -129,12 +130,21 @@ private static function createValidationCallback( TypeValidatorRegistry $registry, ?TypeNode $keyTypeNode, ?TypeNode $itemTypeNode, - string $prefix + string $prefix, + string $function = '' ): \Closure { - return function (mixed $key, mixed $value) use ($registry, $keyTypeNode, $itemTypeNode, $prefix): void { + return function (mixed $key, mixed $value) use ($registry, $keyTypeNode, $itemTypeNode, $prefix, $function): void { if ($keyTypeNode !== null && $key !== null) { $err = $registry->validate($key, $keyTypeNode, "$prefix key"); if ($err !== null) { + if ($function !== '' && CallerBoundaryResolver::shouldBypass($function)) { + return; + } + + if (CallerBoundaryResolver::isCallerVendor()) { + return; + } + throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); } } @@ -142,6 +152,14 @@ private static function createValidationCallback( if ($itemTypeNode !== null) { $err = $registry->validate($value, $itemTypeNode, "$prefix value"); if ($err !== null) { + if ($function !== '' && CallerBoundaryResolver::shouldBypass($function)) { + return; + } + + if (CallerBoundaryResolver::isCallerVendor()) { + return; + } + throw ErrorFactory::prepareException(new TypePHPTypeError($err->getMessage())); } } @@ -163,4 +181,4 @@ private static function wrapGenerator(iterable $iterable, \Closure $typeCheckCal yield $key => $value; } } -} +} \ No newline at end of file diff --git a/tests/TypeChecking/Boundaries/VendorBoundaryIsolationTest.php b/tests/TypeChecking/Boundaries/VendorBoundaryIsolationTest.php new file mode 100644 index 00000000..737bca0e --- /dev/null +++ b/tests/TypeChecking/Boundaries/VendorBoundaryIsolationTest.php @@ -0,0 +1,327 @@ + true, + 'include' => [ + 'tests/**', + $normTempDir . '/vendor/acme/sample-lib/**', + ], + 'exclude' => [ + 'vendor/**', + ], + ]); + + StreamWrapper::register(); + + require_once $vendorFile; + + $service = new \Simulated\Vendor\WhitelistedService(); + + expect($service->processCode(100))->toBe(100); + + expect(fn () => $service->processCode(-5)) + ->toThrow(TypeError::class, 'positive-int') + ; + } finally { + if (file_exists($vendorFile)) { + @unlink($vendorFile); + } + if (is_dir($vendorDir)) { + @rmdir($vendorDir); + @rmdir($tempDir . '/vendor/acme/sample-lib'); + @rmdir($tempDir . '/vendor/acme'); + @rmdir($tempDir . '/vendor'); + @rmdir($tempDir); + } + } + }); + + test('bypasses type enforcement when call originates from an excluded vendor package', function () { + $tempDir = sys_get_temp_dir() . '/typephp_boundary_bypass_' . uniqid(); + $whitelistedDir = $tempDir . '/vendor/acme/collection/src'; + $excludedDir = $tempDir . '/vendor/third-party/caller/src'; + + mkdir($whitelistedDir, 0777, true); + mkdir($excludedDir, 0777, true); + + $whitelistedFile = $whitelistedDir . '/WhitelistedCollection.php'; + $excludedFile = $excludedDir . '/ExcludedVendorCaller.php'; + + $whitelistedSource = <<<'PHP' +find(-999); + } +} +PHP; + + file_put_contents($whitelistedFile, $whitelistedSource); + file_put_contents($excludedFile, $excludedSource); + + try { + $normTempDir = str_replace('\\', '/', $tempDir); + + Config::set([ + 'vendor_boundary_only' => true, + 'include' => [ + 'tests/**', + $normTempDir . '/vendor/acme/collection/**', // Whitelisted + ], + 'exclude' => [ + $normTempDir . '/vendor/third-party/**', // Excluded Vendor + ], + ]); + + StreamWrapper::register(); + + require_once $whitelistedFile; + require_once $excludedFile; + + $collection = new \Simulated\Collections\WhitelistedCollection(); + $caller = new \Simulated\ThirdParty\ExcludedVendorCaller(); + + $result = $caller->makeInvalidVendorCall($collection); + expect($result)->toBe(-999); + } finally { + @unlink($whitelistedFile); + @unlink($excludedFile); + @rmdir($whitelistedDir); + @rmdir($excludedDir); + @rmdir($tempDir . '/vendor/acme/collection'); + @rmdir($tempDir . '/vendor/acme'); + @rmdir($tempDir . '/vendor/third-party/caller'); + @rmdir($tempDir . '/vendor/third-party'); + @rmdir($tempDir . '/vendor'); + @rmdir($tempDir); + } + }); + + test('bypasses type enforcement when a whitelisted vendor class performs an internal self-call', function () { + $tempDir = sys_get_temp_dir() . '/typephp_self_call_' . uniqid(); + $vendorDir = $tempDir . '/vendor/acme/morph/src'; + mkdir($vendorDir, 0777, true); + + $vendorFile = $vendorDir . '/MorphService.php'; + $vendorSource = <<<'PHP' +setStrictCount(-42); + } +} +PHP; + file_put_contents($vendorFile, $vendorSource); + + try { + $normTempDir = str_replace('\\', '/', $tempDir); + + Config::set([ + 'vendor_boundary_only' => true, + 'include' => [ + 'tests/**', + $normTempDir . '/vendor/acme/morph/**', + ], + 'exclude' => [ + 'vendor/**', + ], + ]); + + StreamWrapper::register(); + + require_once $vendorFile; + + $morph = new \Simulated\Morph\MorphService(); + + expect($morph->internalSelfMethod())->toBe(-42); + expect(fn () => $morph->setStrictCount(-42)) + ->toThrow(TypeError::class, 'positive-int') + ; + } finally { + @unlink($vendorFile); + @rmdir($vendorDir); + @rmdir($tempDir . '/vendor/acme/morph'); + @rmdir($tempDir . '/vendor/acme'); + @rmdir($tempDir . '/vendor'); + @rmdir($tempDir); + } + }); + + test('strictly enforces types across all callers when vendor_boundary_only is false', function () { + $tempDir = sys_get_temp_dir() . '/typephp_strict_all_' . uniqid(); + $whitelistedDir = $tempDir . '/vendor/acme/strict/src'; + $excludedDir = $tempDir . '/vendor/third-party/strict-caller/src'; + + mkdir($whitelistedDir, 0777, true); + mkdir($excludedDir, 0777, true); + + $whitelistedFile = $whitelistedDir . '/StrictService.php'; + $excludedFile = $excludedDir . '/StrictCaller.php'; + + $whitelistedSource = <<<'PHP' +requirePositive(-100); + } +} +PHP; + + file_put_contents($whitelistedFile, $whitelistedSource); + file_put_contents($excludedFile, $excludedSource); + + try { + $normTempDir = str_replace('\\', '/', $tempDir); + + Config::set([ + 'vendor_boundary_only' => false, + 'include' => [ + 'tests/**', + $normTempDir . '/vendor/acme/strict/**', + ], + 'exclude' => [ + $normTempDir . '/vendor/third-party/**', + ], + ]); + + StreamWrapper::register(); + + require_once $whitelistedFile; + require_once $excludedFile; + + $service = new \Simulated\Strict\StrictService(); + $caller = new \Simulated\StrictCaller\StrictCaller(); + + expect(fn () => $caller->execute($service)) + ->toThrow(TypeError::class, 'positive-int') + ; + } finally { + @unlink($whitelistedFile); + @unlink($excludedFile); + @rmdir($whitelistedDir); + @rmdir($excludedDir); + @rmdir($tempDir . '/vendor/acme/strict'); + @rmdir($tempDir . '/vendor/acme'); + @rmdir($tempDir . '/vendor/third-party/strict-caller'); + @rmdir($tempDir . '/vendor/third-party'); + @rmdir($tempDir . '/vendor'); + @rmdir($tempDir); + } + }); +}); \ No newline at end of file From 97c51ef7b894a752eaeeb14e6176219da9d979a7 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 11 Sep 2026 23:33:00 +0800 Subject: [PATCH 2/4] Optimize setupScope and check methods to reduce redundant checks and improve performance --- src/Internal/RuntimeTypeChecker.php | 72 +++++++++++++++++++---------- 1 file changed, 47 insertions(+), 25 deletions(-) diff --git a/src/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index 3cf9a3a2..1242833c 100644 --- a/src/Internal/RuntimeTypeChecker.php +++ b/src/Internal/RuntimeTypeChecker.php @@ -130,38 +130,33 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string /** * Initialises generic call frames and returns a ScopeCleaner that pops the call frame on destruction. * - * Optimized: combines Config checks, uses pre-computed contract flags, - * and passes contract through to checkParams to avoid re-parsing. - * * @param array $vars */ public static function setupScope(string $function, array $vars, object|string|null $thisOrClass = null): ErrorMessage|ScopeCleaner|null { - // Combined config gate — single check instead of two separate calls if (! Config::isEnabled() || ! Config::isParamsEnabled()) { return null; } + $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; + $effectiveFunction = ParamChecker::resolveEffectiveFunction($function, $thisOrClass, $thisObj); + + if (CallerBoundaryResolver::shouldBypass($effectiveFunction)) { + return null; + } + if (isset(ParamChecker::$noParamContractCache[$function]) && ! (self::$hasMethodTemplatesCache[$function] ?? false)) { return null; } - $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; - $effectiveFunction = ParamChecker::resolveEffectiveFunction($function, $thisOrClass, $thisObj); - - // FIX: Magic methods (__call/__callStatic) must NOT bail out early, - // even if __call itself has unconstrained (mixed) parameters, - // because the actual validation happens inside ParamChecker::handleMagicCall(). $isMagicCall = str_contains($effectiveFunction, '__call'); - // Fast bail using pre-computed flag from contract (skip for magic calls) $contract = DocblockParser::parse($effectiveFunction); if (! $isMagicCall && ($contract['allParamsUnconstrained'] ?? false)) { return null; } - // Pass effectiveFunction AND contract to checkParams to avoid re-parsing $err = ParamChecker::checkParams( $function, $vars, @@ -203,6 +198,13 @@ public static function checkParams(string $function, array $vars, object|string| return null; } + $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; + $effectiveFunction = ParamChecker::resolveEffectiveFunction($function, $thisOrClass, $thisObj); + + if (CallerBoundaryResolver::shouldBypass($effectiveFunction)) { + return null; + } + $err = ParamChecker::checkParams($function, $vars, $thisOrClass, self::getRegistry()); if ($err !== null && (IgnoreManager::isCallerIgnored() || CallerBoundaryResolver::shouldBypass($function))) { @@ -215,36 +217,29 @@ public static function checkParams(string $function, array $vars, object|string| /** * Validates a function or method's return value against its declared contract and returns value or ErrorMessage. * - * Optimized: combines Config checks, resolves effectiveFunction once, - * uses pre-computed contract flags, and passes both effectiveFunction and contract - * to ReturnChecker to avoid redundant resolution and parsing. - * * @param array|null $vars */ public static function checkReturn(string $function, mixed $value, object|string|null $thisOrClass = null, ?array $vars = []): mixed { - // Combined config gate — single check instead of two separate calls if (! Config::isEnabled() || ! Config::isReturnsEnabled()) { return $value; } - if (isset(ReturnChecker::$noReturnContractCache[$function])) { - return $value; - } - $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; $effectiveFunction = ParamChecker::resolveEffectiveFunction($function, $thisOrClass, $thisObj); - if (isset(ReturnChecker::$noReturnContractCache[$effectiveFunction])) { + if (CallerBoundaryResolver::shouldBypass($effectiveFunction)) { + return $value; + } + + if (isset(ReturnChecker::$noReturnContractCache[$function]) || isset(ReturnChecker::$noReturnContractCache[$effectiveFunction])) { ReturnChecker::$noReturnContractCache[$function] = true; return $value; } - // FIX: Magic methods must NOT bail out early $isMagicCall = str_contains($effectiveFunction, '__call'); - // Fast bail using pre-computed flag from contract (skip for magic calls) $contract = DocblockParser::parse($effectiveFunction); if (! $isMagicCall && ($contract['returnUnconstrained'] ?? false)) { return $value; @@ -252,7 +247,6 @@ public static function checkReturn(string $function, mixed $value, object|string $vars ??= []; - // Pass effectiveFunction AND contract to ReturnChecker to avoid re-parsing $res = ReturnChecker::checkReturn( $function, $value, @@ -280,6 +274,13 @@ public static function checkSend(string $function, mixed $sendValue, object|stri return $sendValue; } + $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; + $effectiveFunction = ParamChecker::resolveEffectiveFunction($function, $thisOrClass, $thisObj); + + if (CallerBoundaryResolver::shouldBypass($effectiveFunction)) { + return $sendValue; + } + $res = GeneratorChecker::checkSend($function, $sendValue, self::getRegistry(), $thisOrClass); if ($res instanceof ErrorMessage && (IgnoreManager::isCallerIgnored() || CallerBoundaryResolver::shouldBypass($function))) { @@ -298,6 +299,13 @@ public static function checkYield(string $function, mixed $key, mixed $value, ob return $value; } + $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; + $effectiveFunction = ParamChecker::resolveEffectiveFunction($function, $thisOrClass, $thisObj); + + if (CallerBoundaryResolver::shouldBypass($effectiveFunction)) { + return $value; + } + $res = GeneratorChecker::checkYield($function, $key, $value, self::getRegistry(), $thisOrClass); if ($res instanceof ErrorMessage && (IgnoreManager::isCallerIgnored() || CallerBoundaryResolver::shouldBypass($function))) { @@ -316,6 +324,13 @@ public static function wrapCallable(string $function, string $paramName, mixed $ return $callable; } + $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; + $effectiveFunction = ParamChecker::resolveEffectiveFunction($function, $thisOrClass, $thisObj); + + if (CallerBoundaryResolver::shouldBypass($effectiveFunction)) { + return $callable; + } + return CallableWrapper::wrap($function, $paramName, $callable, self::getRegistry(), $thisOrClass); } @@ -328,6 +343,13 @@ public static function wrapIterable(string $function, string $paramName, mixed $ return $iterable; } + $thisObj = \is_object($thisOrClass) ? $thisOrClass : null; + $effectiveFunction = ParamChecker::resolveEffectiveFunction($function, $thisOrClass, $thisObj); + + if (CallerBoundaryResolver::shouldBypass($effectiveFunction)) { + return $iterable; + } + return IterableWrapper::wrap($function, $paramName, $iterable, self::getRegistry(), $thisOrClass); } From e1f24efb0c89f1bf7df1cfc2bc8f497bdb67cf37 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 12 Sep 2026 13:05:39 +0800 Subject: [PATCH 3/4] Improve cross plarform support --- .../VendorBoundaryIsolationTest.php | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/tests/TypeChecking/Boundaries/VendorBoundaryIsolationTest.php b/tests/TypeChecking/Boundaries/VendorBoundaryIsolationTest.php index 737bca0e..8c3d2d24 100644 --- a/tests/TypeChecking/Boundaries/VendorBoundaryIsolationTest.php +++ b/tests/TypeChecking/Boundaries/VendorBoundaryIsolationTest.php @@ -2,8 +2,6 @@ declare(strict_types=1); -namespace TypePHP\Tests\TypeChecking\Boundaries; - use TypePHP\Exception\TypeError; use TypePHP\Internal\Io\StreamWrapper; use TypePHP\Internal\Resolver\CallerBoundaryResolver; @@ -23,7 +21,10 @@ }); test('enforces types when application code calls whitelisted vendor methods', function () { - $tempDir = sys_get_temp_dir() . '/typephp_vendor_test_' . uniqid(); + $baseTemp = sys_get_temp_dir() . '/typephp_vendor_test_' . uniqid(); + mkdir($baseTemp, 0777, true); + $tempDir = realpath($baseTemp) !== false ? realpath($baseTemp) : $baseTemp; + $vendorDir = $tempDir . '/vendor/acme/sample-lib/src'; mkdir($vendorDir, 0777, true); @@ -68,7 +69,6 @@ public function processCode(int $code): int $service = new \Simulated\Vendor\WhitelistedService(); expect($service->processCode(100))->toBe(100); - expect(fn () => $service->processCode(-5)) ->toThrow(TypeError::class, 'positive-int') ; @@ -87,7 +87,10 @@ public function processCode(int $code): int }); test('bypasses type enforcement when call originates from an excluded vendor package', function () { - $tempDir = sys_get_temp_dir() . '/typephp_boundary_bypass_' . uniqid(); + $baseTemp = sys_get_temp_dir() . '/typephp_boundary_bypass_' . uniqid(); + mkdir($baseTemp, 0777, true); + $tempDir = realpath($baseTemp) !== false ? realpath($baseTemp) : $baseTemp; + $whitelistedDir = $tempDir . '/vendor/acme/collection/src'; $excludedDir = $tempDir . '/vendor/third-party/caller/src'; @@ -142,10 +145,10 @@ public function makeInvalidVendorCall(WhitelistedCollection $collection): int 'vendor_boundary_only' => true, 'include' => [ 'tests/**', - $normTempDir . '/vendor/acme/collection/**', // Whitelisted + $normTempDir . '/vendor/acme/collection/**', ], 'exclude' => [ - $normTempDir . '/vendor/third-party/**', // Excluded Vendor + $normTempDir . '/vendor/third-party/**', ], ]); @@ -156,7 +159,6 @@ public function makeInvalidVendorCall(WhitelistedCollection $collection): int $collection = new \Simulated\Collections\WhitelistedCollection(); $caller = new \Simulated\ThirdParty\ExcludedVendorCaller(); - $result = $caller->makeInvalidVendorCall($collection); expect($result)->toBe(-999); } finally { @@ -174,7 +176,10 @@ public function makeInvalidVendorCall(WhitelistedCollection $collection): int }); test('bypasses type enforcement when a whitelisted vendor class performs an internal self-call', function () { - $tempDir = sys_get_temp_dir() . '/typephp_self_call_' . uniqid(); + $baseTemp = sys_get_temp_dir() . '/typephp_self_call_' . uniqid(); + mkdir($baseTemp, 0777, true); + $tempDir = realpath($baseTemp) !== false ? realpath($baseTemp) : $baseTemp; + $vendorDir = $tempDir . '/vendor/acme/morph/src'; mkdir($vendorDir, 0777, true); @@ -239,7 +244,10 @@ public function internalSelfMethod(): int }); test('strictly enforces types across all callers when vendor_boundary_only is false', function () { - $tempDir = sys_get_temp_dir() . '/typephp_strict_all_' . uniqid(); + $baseTemp = sys_get_temp_dir() . '/typephp_strict_all_' . uniqid(); + mkdir($baseTemp, 0777, true); + $tempDir = realpath($baseTemp) !== false ? realpath($baseTemp) : $baseTemp; + $whitelistedDir = $tempDir . '/vendor/acme/strict/src'; $excludedDir = $tempDir . '/vendor/third-party/strict-caller/src'; From 7c20af80068392ff6552051091d7f61e5841db16 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 12 Sep 2026 13:19:02 +0800 Subject: [PATCH 4/4] Fix phpstan errors --- src/Internal/Cli/ConfigInitCommand.php | 2 +- .../Resolver/CallerBoundaryResolver.php | 11 ++-------- src/Internal/RuntimeTypeChecker.php | 12 +++++------ src/Internal/Util/Config.php | 2 +- src/Internal/Util/PathMatcher.php | 2 +- src/Internal/Wrapper/CallableWrapper.php | 2 +- src/Internal/Wrapper/IterableWrapper.php | 2 +- .../VendorBoundaryIsolationTest.php | 20 +++++++++---------- 8 files changed, 23 insertions(+), 30 deletions(-) diff --git a/src/Internal/Cli/ConfigInitCommand.php b/src/Internal/Cli/ConfigInitCommand.php index af9a595c..9fbc66ce 100644 --- a/src/Internal/Cli/ConfigInitCommand.php +++ b/src/Internal/Cli/ConfigInitCommand.php @@ -235,4 +235,4 @@ private static function getTemplate(): string ]; PHP; } -} \ No newline at end of file +} diff --git a/src/Internal/Resolver/CallerBoundaryResolver.php b/src/Internal/Resolver/CallerBoundaryResolver.php index dfe9bd46..d5c537fd 100644 --- a/src/Internal/Resolver/CallerBoundaryResolver.php +++ b/src/Internal/Resolver/CallerBoundaryResolver.php @@ -42,12 +42,10 @@ public static function shouldBypass(string $function): bool return false; } - // 1. If the callee itself is an application class/function, ALWAYS enforce! if (! self::isCalleeVendor($function)) { return false; } - // 2. If the callee is in vendor, check who initiated the call return self::isCallerVendor(); } @@ -60,7 +58,6 @@ public static function shouldBypassCallback(mixed $callable, string $prefix = '' return false; } - // If the closure was defined inside a vendor file, bypass! if ($callable instanceof Closure) { try { $ref = new ReflectionFunction($callable); @@ -122,15 +119,12 @@ public static function isCallerVendor(): bool for ($i = 1; $i < \count($trace); $i++) { $frame = $trace[$i]; $class = $frame['class'] ?? ''; - $function = $frame['function'] ?? ''; + $function = $frame['function']; $file = $frame['file'] ?? ''; - - // Skip internal TypePHP engine frames if ($class !== '' && (str_starts_with($class, 'TypePHP\\Internal\\') || $class === 'TypePHP\\TypePHP')) { continue; } - // Skip standard procedural call_user_func wrappers if ($class === '' && \in_array($function, ['call_user_func', 'call_user_func_array'], true)) { continue; } @@ -145,7 +139,6 @@ public static function isCallerVendor(): bool continue; } - // Skip test runner framework internals (PHPUnit / Pest constraint wrappers) if ( str_contains($normalizedFile, '/vendor/phpunit/') || str_contains($normalizedFile, '/vendor/pestphp/') @@ -164,4 +157,4 @@ public static function isCallerVendor(): bool return false; } -} \ No newline at end of file +} diff --git a/src/Internal/RuntimeTypeChecker.php b/src/Internal/RuntimeTypeChecker.php index 1242833c..48d9b959 100644 --- a/src/Internal/RuntimeTypeChecker.php +++ b/src/Internal/RuntimeTypeChecker.php @@ -167,7 +167,7 @@ public static function setupScope(string $function, array $vars, object|string|n ); if ($err !== null) { - if (IgnoreManager::isCallerIgnored() || CallerBoundaryResolver::shouldBypass($effectiveFunction)) { + if (IgnoreManager::isCallerIgnored()) { return null; } @@ -207,7 +207,7 @@ public static function checkParams(string $function, array $vars, object|string| $err = ParamChecker::checkParams($function, $vars, $thisOrClass, self::getRegistry()); - if ($err !== null && (IgnoreManager::isCallerIgnored() || CallerBoundaryResolver::shouldBypass($function))) { + if ($err !== null && IgnoreManager::isCallerIgnored()) { return null; } @@ -258,7 +258,7 @@ public static function checkReturn(string $function, mixed $value, object|string $contract ); - if ($res instanceof ErrorMessage && (IgnoreManager::isCallerIgnored() || CallerBoundaryResolver::shouldBypass($effectiveFunction))) { + if ($res instanceof ErrorMessage && IgnoreManager::isCallerIgnored()) { return $value; } @@ -283,7 +283,7 @@ public static function checkSend(string $function, mixed $sendValue, object|stri $res = GeneratorChecker::checkSend($function, $sendValue, self::getRegistry(), $thisOrClass); - if ($res instanceof ErrorMessage && (IgnoreManager::isCallerIgnored() || CallerBoundaryResolver::shouldBypass($function))) { + if ($res instanceof ErrorMessage && IgnoreManager::isCallerIgnored()) { return $sendValue; } @@ -308,7 +308,7 @@ public static function checkYield(string $function, mixed $key, mixed $value, ob $res = GeneratorChecker::checkYield($function, $key, $value, self::getRegistry(), $thisOrClass); - if ($res instanceof ErrorMessage && (IgnoreManager::isCallerIgnored() || CallerBoundaryResolver::shouldBypass($function))) { + if ($res instanceof ErrorMessage && IgnoreManager::isCallerIgnored()) { return $value; } @@ -394,4 +394,4 @@ public static function getRegistry(): TypeValidatorRegistry { return self::$registry ??= new TypeValidatorRegistry(); } -} \ No newline at end of file +} diff --git a/src/Internal/Util/Config.php b/src/Internal/Util/Config.php index 77b17c4c..94adb2ad 100644 --- a/src/Internal/Util/Config.php +++ b/src/Internal/Util/Config.php @@ -411,4 +411,4 @@ private static function syncFlags(array $config): void self::$vendorBoundaryOnly = (bool) ($config['vendor_boundary_only'] ?? true); self::$arrayValidation = \is_string($config['array_validation'] ?? null) ? $config['array_validation'] : 'full'; } -} \ No newline at end of file +} diff --git a/src/Internal/Util/PathMatcher.php b/src/Internal/Util/PathMatcher.php index d420c8d9..7a0b2d1b 100644 --- a/src/Internal/Util/PathMatcher.php +++ b/src/Internal/Util/PathMatcher.php @@ -448,4 +448,4 @@ private static function getCompiledPatterns(array $globs, string $baseDir, strin return self::$compiledExcludesCache[$cacheKey] = $compiled; } -} \ No newline at end of file +} diff --git a/src/Internal/Wrapper/CallableWrapper.php b/src/Internal/Wrapper/CallableWrapper.php index 5e91d131..75b24170 100644 --- a/src/Internal/Wrapper/CallableWrapper.php +++ b/src/Internal/Wrapper/CallableWrapper.php @@ -245,4 +245,4 @@ private static function validateCallbackArguments( } } } -} \ No newline at end of file +} diff --git a/src/Internal/Wrapper/IterableWrapper.php b/src/Internal/Wrapper/IterableWrapper.php index cc0b18a9..aa2f239b 100644 --- a/src/Internal/Wrapper/IterableWrapper.php +++ b/src/Internal/Wrapper/IterableWrapper.php @@ -181,4 +181,4 @@ private static function wrapGenerator(iterable $iterable, \Closure $typeCheckCal yield $key => $value; } } -} \ No newline at end of file +} diff --git a/tests/TypeChecking/Boundaries/VendorBoundaryIsolationTest.php b/tests/TypeChecking/Boundaries/VendorBoundaryIsolationTest.php index 8c3d2d24..0daf2d77 100644 --- a/tests/TypeChecking/Boundaries/VendorBoundaryIsolationTest.php +++ b/tests/TypeChecking/Boundaries/VendorBoundaryIsolationTest.php @@ -66,7 +66,7 @@ public function processCode(int $code): int require_once $vendorFile; - $service = new \Simulated\Vendor\WhitelistedService(); + $service = new Simulated\Vendor\WhitelistedService(); expect($service->processCode(100))->toBe(100); expect(fn () => $service->processCode(-5)) @@ -145,10 +145,10 @@ public function makeInvalidVendorCall(WhitelistedCollection $collection): int 'vendor_boundary_only' => true, 'include' => [ 'tests/**', - $normTempDir . '/vendor/acme/collection/**', + $normTempDir . '/vendor/acme/collection/**', ], 'exclude' => [ - $normTempDir . '/vendor/third-party/**', + $normTempDir . '/vendor/third-party/**', ], ]); @@ -157,8 +157,8 @@ public function makeInvalidVendorCall(WhitelistedCollection $collection): int require_once $whitelistedFile; require_once $excludedFile; - $collection = new \Simulated\Collections\WhitelistedCollection(); - $caller = new \Simulated\ThirdParty\ExcludedVendorCaller(); + $collection = new Simulated\Collections\WhitelistedCollection(); + $caller = new Simulated\ThirdParty\ExcludedVendorCaller(); $result = $caller->makeInvalidVendorCall($collection); expect($result)->toBe(-999); } finally { @@ -227,7 +227,7 @@ public function internalSelfMethod(): int require_once $vendorFile; - $morph = new \Simulated\Morph\MorphService(); + $morph = new Simulated\Morph\MorphService(); expect($morph->internalSelfMethod())->toBe(-42); expect(fn () => $morph->setStrictCount(-42)) @@ -298,7 +298,7 @@ public function execute(StrictService $service): int $normTempDir = str_replace('\\', '/', $tempDir); Config::set([ - 'vendor_boundary_only' => false, + 'vendor_boundary_only' => false, 'include' => [ 'tests/**', $normTempDir . '/vendor/acme/strict/**', @@ -313,8 +313,8 @@ public function execute(StrictService $service): int require_once $whitelistedFile; require_once $excludedFile; - $service = new \Simulated\Strict\StrictService(); - $caller = new \Simulated\StrictCaller\StrictCaller(); + $service = new Simulated\Strict\StrictService(); + $caller = new Simulated\StrictCaller\StrictCaller(); expect(fn () => $caller->execute($service)) ->toThrow(TypeError::class, 'positive-int') @@ -332,4 +332,4 @@ public function execute(StrictService $service): int @rmdir($tempDir); } }); -}); \ No newline at end of file +});