Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/Internal/Cli/ConfigInitCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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',

Expand Down
160 changes: 160 additions & 0 deletions src/Internal/Resolver/CallerBoundaryResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
<?php

declare(strict_types=1);

namespace TypePHP\Internal\Resolver;

use Closure;
use ReflectionClass;
use ReflectionFunction;
use Throwable;
use TypePHP\Internal\Util\Config;
use TypePHP\Internal\Util\PathMatcher;

/**
* @internal Resolves whether a type check failure should be bypassed because it occurred at a vendor-to-vendor boundary.
*/
final class CallerBoundaryResolver
{
/**
* @var array<string, bool> functionName => isCalleeVendor
*/
private static array $calleeVendorCache = [];

/**
* @var array<string, bool> 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;
}

if (! self::isCalleeVendor($function)) {
return false;
}

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 ($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<object> $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'] ?? '';
if ($class !== '' && (str_starts_with($class, 'TypePHP\\Internal\\') || $class === 'TypePHP\\TypePHP')) {
continue;
}

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;
}

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;
}
}
80 changes: 52 additions & 28 deletions src/Internal/RuntimeTypeChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -42,6 +43,7 @@ public static function reset(): void
{
self::$hasMethodTemplatesCache = [];
IgnoreManager::reset();
CallerBoundaryResolver::reset();
}

/**
Expand All @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}

Expand All @@ -128,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<string, mixed> $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,
Expand Down Expand Up @@ -201,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()) {
Expand All @@ -213,44 +217,36 @@ 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<string, mixed>|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;
}

$vars ??= [];

// Pass effectiveFunction AND contract to ReturnChecker to avoid re-parsing
$res = ReturnChecker::checkReturn(
$function,
$value,
Expand Down Expand Up @@ -278,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()) {
Expand All @@ -296,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()) {
Expand All @@ -314,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);
}

Expand All @@ -326,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);
}

Expand Down
Loading
Loading